feat(bj): модуль ЛК на PostgreSQL, обмен с DEPO, боевой ЛК с имитатором НРД, QUIK, НПР
- bj_exchange и «кран» Fancy убраны: данные модуля ЛК в PostgreSQL (схема lk) - трансферные таблицы FTV_M2M: создание при установке, события, справочник участников - сверка с DEPO: счёт, ФИО, дата рождения, ИИС по договору, квал-инвестор из опции DEPO - боевой ЛК ЕСИА Финанс при тестовом ИШ: журнал, отказ через DEPO, статусы в ЛК - онлайн-лицензирование в bj, план тестирования M2M для контрагента (интерактивный HTML) - модули QUIK МНП, экспорт QUIK, НПР/УДС; документы и тестовые материалы Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,447 @@
|
||||
// Команда bj-lk — Личный кабинет клиента (отдельный модуль).
|
||||
//
|
||||
// Ставится на любой веб-сервер клиента. Сам к базам данных НЕ подключается —
|
||||
// работает только с bj-server по HTTP API с ключом доступа. Клиент входит по
|
||||
// логину/паролю, видит свои бумаги и подаёт заявку на перевод другому брокеру.
|
||||
//
|
||||
// Настройка — через переменные окружения или флаги:
|
||||
//
|
||||
// BJ_URL адрес bj-server (напр. https://bj.example.ru)
|
||||
// BJ_KEY ключ доступа модуля (выдаётся в админке bj-server)
|
||||
// LISTEN адрес прослушивания (по умолчанию :8081)
|
||||
// LK_TITLE название кабинета в шапке (по умолчанию «Личный кабинет»)
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"flag"
|
||||
"fmt"
|
||||
"html/template"
|
||||
"log"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// cookieTTL — срок жизни кук кабинета. Должен быть не меньше срока сессии на
|
||||
// bj-server (12ч), иначе браузер забудет токен раньше, чем тот протухнет, и
|
||||
// клиент увидит «сессия истекла». Кука продлевается при каждом запросе.
|
||||
const cookieTTL = 12 * 3600
|
||||
|
||||
type config struct {
|
||||
BJURL string
|
||||
BJKey string
|
||||
Listen string
|
||||
Title string
|
||||
}
|
||||
|
||||
func loadConfig() config {
|
||||
c := config{
|
||||
BJURL: os.Getenv("BJ_URL"),
|
||||
BJKey: os.Getenv("BJ_KEY"),
|
||||
Listen: os.Getenv("LISTEN"),
|
||||
Title: os.Getenv("LK_TITLE"),
|
||||
}
|
||||
flag.StringVar(&c.BJURL, "bj-url", c.BJURL, "адрес bj-server (https://...)")
|
||||
flag.StringVar(&c.BJKey, "bj-key", c.BJKey, "ключ доступа модуля")
|
||||
flag.StringVar(&c.Listen, "listen", c.Listen, "адрес прослушивания (:8081)")
|
||||
flag.StringVar(&c.Title, "title", c.Title, "заголовок кабинета")
|
||||
flag.Parse()
|
||||
if c.Listen == "" {
|
||||
c.Listen = ":8081"
|
||||
}
|
||||
if c.Title == "" {
|
||||
c.Title = "Личный кабинет"
|
||||
}
|
||||
c.BJURL = strings.TrimRight(c.BJURL, "/")
|
||||
return c
|
||||
}
|
||||
|
||||
// ---- клиент к bj-server ----
|
||||
|
||||
type bjClient struct {
|
||||
cfg config
|
||||
hc *http.Client
|
||||
}
|
||||
|
||||
func (b *bjClient) call(ctx context.Context, method, path, session string, body any, out any) error {
|
||||
var rdr *bytes.Reader
|
||||
if body != nil {
|
||||
raw, _ := json.Marshal(body)
|
||||
rdr = bytes.NewReader(raw)
|
||||
} else {
|
||||
rdr = bytes.NewReader(nil)
|
||||
}
|
||||
req, err := http.NewRequestWithContext(ctx, method, b.cfg.BJURL+path, rdr)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
req.Header.Set("X-BJ-Key", b.cfg.BJKey)
|
||||
if session != "" {
|
||||
req.Header.Set("X-BJ-Session", session)
|
||||
}
|
||||
if body != nil {
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
}
|
||||
resp, err := b.hc.Do(req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("нет связи с сервером: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode >= 400 {
|
||||
var e struct{ Error string }
|
||||
_ = json.NewDecoder(resp.Body).Decode(&e)
|
||||
if e.Error == "" {
|
||||
e.Error = fmt.Sprintf("ошибка сервера (%d)", resp.StatusCode)
|
||||
}
|
||||
return fmt.Errorf("%s", e.Error)
|
||||
}
|
||||
if out != nil {
|
||||
return json.NewDecoder(resp.Body).Decode(out)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ---- модели ответов ----
|
||||
|
||||
type position struct {
|
||||
ISIN string `json:"isin"`
|
||||
SecurityCode string `json:"security_code"`
|
||||
SecurityName string `json:"security_name"`
|
||||
Quantity float64 `json:"quantity"`
|
||||
Blocked float64 `json:"blocked"`
|
||||
Available float64 `json:"available"`
|
||||
UpdatedAt string `json:"updated_at"`
|
||||
Transferable bool `json:"transferable"`
|
||||
Note string `json:"note"`
|
||||
}
|
||||
|
||||
type claimRow struct {
|
||||
ID int64 `json:"id"`
|
||||
GUID string `json:"guid"`
|
||||
StatusRu string `json:"status_ru"`
|
||||
Status string `json:"status"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
Securit int `json:"securities"`
|
||||
Reason string `json:"reason"`
|
||||
}
|
||||
|
||||
func main() {
|
||||
cfg := loadConfig()
|
||||
if cfg.BJURL == "" || cfg.BJKey == "" {
|
||||
log.Fatal("не заданы BJ_URL и BJ_KEY — укажите адрес bj-server и ключ доступа (см. инструкцию)")
|
||||
}
|
||||
bj := &bjClient{cfg: cfg, hc: &http.Client{Timeout: 20 * time.Second}}
|
||||
tpl := template.Must(template.New("t").Funcs(template.FuncMap{
|
||||
"fmtnum": func(f float64) string {
|
||||
if f == float64(int64(f)) {
|
||||
return strconv.FormatInt(int64(f), 10)
|
||||
}
|
||||
return strconv.FormatFloat(f, 'f', -1, 64)
|
||||
},
|
||||
"shortid": func(s string) string {
|
||||
if len(s) > 8 {
|
||||
return s[:8] + "…"
|
||||
}
|
||||
return s
|
||||
},
|
||||
}).Parse(pageTemplates))
|
||||
|
||||
mux := http.NewServeMux()
|
||||
// признаки режима (админ / просмотр кабинета клиента) — из кук
|
||||
var curReq *http.Request
|
||||
render := func(w http.ResponseWriter, name string, data map[string]any) {
|
||||
if data == nil {
|
||||
data = map[string]any{}
|
||||
}
|
||||
data["Title"] = cfg.Title
|
||||
if _, ok := data["Nav"]; !ok {
|
||||
data["Nav"] = ""
|
||||
}
|
||||
if curReq != nil {
|
||||
if _, ok := data["IsAdmin"]; !ok {
|
||||
if c, err := curReq.Cookie("bj_lk_admin"); err == nil && c.Value == "1" {
|
||||
data["IsAdmin"] = true
|
||||
}
|
||||
}
|
||||
if c, err := curReq.Cookie("bj_lk_as"); err == nil && c.Value != "" {
|
||||
data["AsClient"] = c.Value
|
||||
}
|
||||
}
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
if err := tpl.ExecuteTemplate(w, name, data); err != nil {
|
||||
log.Printf("шаблон %s: %v", name, err)
|
||||
}
|
||||
}
|
||||
setFlag := func(w http.ResponseWriter, name, val string) {
|
||||
age := cookieTTL
|
||||
if val == "" {
|
||||
age = -1
|
||||
}
|
||||
http.SetCookie(w, &http.Cookie{Name: name, Value: val, Path: "/", MaxAge: age, SameSite: http.SameSiteLaxMode})
|
||||
}
|
||||
// sessionW — как session, но заодно продлевает куку, чтобы у активного
|
||||
// клиента кабинет не сбрасывался на середине работы.
|
||||
sessionW := func(w http.ResponseWriter, r *http.Request) string {
|
||||
curReq = r
|
||||
c, err := r.Cookie("bj_lk")
|
||||
if err != nil || c.Value == "" {
|
||||
return ""
|
||||
}
|
||||
http.SetCookie(w, &http.Cookie{Name: "bj_lk", Value: c.Value, Path: "/",
|
||||
HttpOnly: true, SameSite: http.SameSiteLaxMode, MaxAge: cookieTTL})
|
||||
return c.Value
|
||||
}
|
||||
session := func(r *http.Request) string {
|
||||
curReq = r
|
||||
c, err := r.Cookie("bj_lk")
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return c.Value
|
||||
}
|
||||
|
||||
// Вход
|
||||
mux.HandleFunc("/login", func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method == http.MethodGet {
|
||||
render(w, "login", map[string]any{"Flash": r.URL.Query().Get("e")})
|
||||
return
|
||||
}
|
||||
_ = r.ParseForm()
|
||||
var out struct {
|
||||
Session string `json:"session"`
|
||||
FullName string `json:"full_name"`
|
||||
Contract string `json:"contract"`
|
||||
IsAdmin bool `json:"is_admin"`
|
||||
}
|
||||
err := bj.call(r.Context(), http.MethodPost, "/api/lk/login", "", map[string]string{
|
||||
"login": r.FormValue("login"), "password": r.FormValue("password"),
|
||||
}, &out)
|
||||
if err != nil {
|
||||
render(w, "login", map[string]any{"Flash": err.Error()})
|
||||
return
|
||||
}
|
||||
http.SetCookie(w, &http.Cookie{Name: "bj_lk", Value: out.Session, Path: "/",
|
||||
HttpOnly: true, SameSite: http.SameSiteLaxMode, MaxAge: cookieTTL})
|
||||
if out.IsAdmin {
|
||||
setFlag(w, "bj_lk_admin", "1")
|
||||
http.Redirect(w, r, "/clients", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
setFlag(w, "bj_lk_admin", "")
|
||||
http.Redirect(w, r, "/", http.StatusSeeOther)
|
||||
})
|
||||
|
||||
mux.HandleFunc("/logout", func(w http.ResponseWriter, r *http.Request) {
|
||||
_ = bj.call(r.Context(), http.MethodPost, "/api/lk/logout", session(r), nil, nil)
|
||||
http.SetCookie(w, &http.Cookie{Name: "bj_lk", Value: "", Path: "/", MaxAge: -1})
|
||||
http.Redirect(w, r, "/login", http.StatusSeeOther)
|
||||
})
|
||||
|
||||
// Портфель
|
||||
mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/" {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
s := sessionW(w, r)
|
||||
if s == "" {
|
||||
http.Redirect(w, r, "/login", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
var out struct {
|
||||
Contract string `json:"contract"`
|
||||
FullName string `json:"full_name"`
|
||||
Positions []position `json:"positions"`
|
||||
Source string `json:"source"`
|
||||
ReadOnly bool `json:"read_only"`
|
||||
}
|
||||
if err := bj.call(r.Context(), http.MethodGet, "/api/lk/portfolio", s, nil, &out); err != nil {
|
||||
render(w, "portfolio", map[string]any{"Nav": "p", "Error": err.Error()})
|
||||
return
|
||||
}
|
||||
render(w, "portfolio", map[string]any{
|
||||
"Nav": "p", "Contract": out.Contract, "FullName": out.FullName,
|
||||
"Positions": out.Positions, "Flash": r.URL.Query().Get("m"),
|
||||
"Source": out.Source, "ReadOnly": out.ReadOnly,
|
||||
})
|
||||
})
|
||||
|
||||
// Заявка на перевод
|
||||
mux.HandleFunc("/transfer", func(w http.ResponseWriter, r *http.Request) {
|
||||
s := sessionW(w, r)
|
||||
if s == "" {
|
||||
http.Redirect(w, r, "/login", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
if r.Method == http.MethodGet {
|
||||
var out struct {
|
||||
Positions []position `json:"positions"`
|
||||
FullName string `json:"full_name"`
|
||||
}
|
||||
if err := bj.call(r.Context(), http.MethodGet, "/api/lk/portfolio", s, nil, &out); err != nil {
|
||||
render(w, "transfer", map[string]any{"Nav": "t", "Error": err.Error()})
|
||||
return
|
||||
}
|
||||
render(w, "transfer", map[string]any{"Nav": "t", "Positions": out.Positions, "FullName": out.FullName})
|
||||
return
|
||||
}
|
||||
_ = r.ParseForm()
|
||||
var secs []map[string]any
|
||||
for _, isin := range r.Form["isin"] {
|
||||
q := strings.TrimSpace(r.FormValue("qty_" + isin))
|
||||
if q == "" || q == "0" {
|
||||
continue
|
||||
}
|
||||
f, err := strconv.ParseFloat(strings.ReplaceAll(q, ",", "."), 64)
|
||||
if err != nil || f <= 0 {
|
||||
continue
|
||||
}
|
||||
secs = append(secs, map[string]any{"isin": isin, "quantity": f})
|
||||
}
|
||||
if len(secs) == 0 {
|
||||
render(w, "transfer", map[string]any{"Nav": "t", "Error": "Укажите количество хотя бы по одной бумаге"})
|
||||
return
|
||||
}
|
||||
var res struct {
|
||||
Message string `json:"message"`
|
||||
GUID string `json:"guid"`
|
||||
}
|
||||
err := bj.call(r.Context(), http.MethodPost, "/api/lk/transfer", s, map[string]any{
|
||||
"receiving_inn": strings.TrimSpace(r.FormValue("receiving_inn")),
|
||||
"receiving_name": strings.TrimSpace(r.FormValue("receiving_name")),
|
||||
"account_at_recv": strings.TrimSpace(r.FormValue("account_at_recv")),
|
||||
"comment": strings.TrimSpace(r.FormValue("comment")),
|
||||
"securities": secs,
|
||||
}, &res)
|
||||
if err != nil {
|
||||
render(w, "transfer", map[string]any{"Nav": "t", "Error": err.Error()})
|
||||
return
|
||||
}
|
||||
http.Redirect(w, r, "/claims?m="+res.Message, http.StatusSeeOther)
|
||||
})
|
||||
|
||||
// Мои заявки
|
||||
mux.HandleFunc("/claims", func(w http.ResponseWriter, r *http.Request) {
|
||||
s := sessionW(w, r)
|
||||
if s == "" {
|
||||
http.Redirect(w, r, "/login", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
var out struct {
|
||||
Claims []claimRow `json:"claims"`
|
||||
}
|
||||
if err := bj.call(r.Context(), http.MethodGet, "/api/lk/claims", s, nil, &out); err != nil {
|
||||
render(w, "claims", map[string]any{"Nav": "c", "Error": err.Error()})
|
||||
return
|
||||
}
|
||||
render(w, "claims", map[string]any{"Nav": "c", "Claims": out.Claims, "Flash": r.URL.Query().Get("m")})
|
||||
})
|
||||
|
||||
// Администратор: список клиентов
|
||||
mux.HandleFunc("/clients", func(w http.ResponseWriter, r *http.Request) {
|
||||
s := sessionW(w, r)
|
||||
if s == "" {
|
||||
http.Redirect(w, r, "/login", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
var out struct {
|
||||
Clients []struct {
|
||||
Login string `json:"login"`
|
||||
Contract string `json:"contract_number"`
|
||||
FullName string `json:"full_name"`
|
||||
Email string `json:"email"`
|
||||
Active bool `json:"active"`
|
||||
IsAdmin bool `json:"is_admin"`
|
||||
} `json:"clients"`
|
||||
Depo []struct {
|
||||
Contract string `json:"contract"`
|
||||
Name string `json:"name"`
|
||||
Securities int `json:"securities"`
|
||||
} `json:"depo"`
|
||||
DepoError string `json:"depo_error"`
|
||||
}
|
||||
search := strings.TrimSpace(r.URL.Query().Get("search"))
|
||||
path := "/api/lk/admin/clients"
|
||||
if search != "" {
|
||||
path += "?search=" + url.QueryEscape(search)
|
||||
}
|
||||
if err := bj.call(r.Context(), http.MethodGet, path, s, nil, &out); err != nil {
|
||||
render(w, "clients", map[string]any{"Nav": "a", "IsAdmin": true, "Error": err.Error()})
|
||||
return
|
||||
}
|
||||
render(w, "clients", map[string]any{"Nav": "a", "IsAdmin": true, "Clients": out.Clients,
|
||||
"Depo": out.Depo, "DepoError": out.DepoError, "Search": search,
|
||||
"Flash": r.URL.Query().Get("m")})
|
||||
})
|
||||
|
||||
// Администратор: войти в кабинет клиента
|
||||
mux.HandleFunc("/impersonate", func(w http.ResponseWriter, r *http.Request) {
|
||||
s := sessionW(w, r)
|
||||
if s == "" {
|
||||
http.Redirect(w, r, "/login", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
login := r.FormValue("login")
|
||||
if login == "" {
|
||||
login = r.URL.Query().Get("login")
|
||||
}
|
||||
contract := r.FormValue("contract")
|
||||
if contract == "" {
|
||||
contract = r.URL.Query().Get("contract")
|
||||
}
|
||||
var out struct {
|
||||
Session string `json:"session"`
|
||||
AsClient string `json:"as_client"`
|
||||
}
|
||||
if err := bj.call(r.Context(), http.MethodPost, "/api/lk/admin/impersonate", s,
|
||||
map[string]string{"login": login, "contract": contract}, &out); err != nil {
|
||||
http.Redirect(w, r, "/clients?m="+err.Error(), http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
// сохраняем админскую сессию, чтобы можно было вернуться
|
||||
if c, err := r.Cookie("bj_lk"); err == nil {
|
||||
setFlag(w, "bj_lk_admin_sess", c.Value)
|
||||
}
|
||||
http.SetCookie(w, &http.Cookie{Name: "bj_lk", Value: out.Session, Path: "/",
|
||||
HttpOnly: true, SameSite: http.SameSiteLaxMode, MaxAge: cookieTTL})
|
||||
setFlag(w, "bj_lk_as", out.AsClient)
|
||||
http.Redirect(w, r, "/", http.StatusSeeOther)
|
||||
})
|
||||
|
||||
// Администратор: вернуться из кабинета клиента
|
||||
mux.HandleFunc("/back-to-admin", func(w http.ResponseWriter, r *http.Request) {
|
||||
if c, err := r.Cookie("bj_lk_admin_sess"); err == nil && c.Value != "" {
|
||||
http.SetCookie(w, &http.Cookie{Name: "bj_lk", Value: c.Value, Path: "/",
|
||||
HttpOnly: true, SameSite: http.SameSiteLaxMode, MaxAge: cookieTTL})
|
||||
}
|
||||
setFlag(w, "bj_lk_as", "")
|
||||
setFlag(w, "bj_lk_admin_sess", "")
|
||||
http.Redirect(w, r, "/clients", http.StatusSeeOther)
|
||||
})
|
||||
|
||||
// Проверка связи с bj-server (для админа при установке)
|
||||
mux.HandleFunc("/health", func(w http.ResponseWriter, r *http.Request) {
|
||||
var out map[string]any
|
||||
err := bj.call(r.Context(), http.MethodGet, "/api/lk/ping", "", nil, &out)
|
||||
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||
if err != nil {
|
||||
w.WriteHeader(http.StatusBadGateway)
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{"ok": false, "error": err.Error(), "bj_url": cfg.BJURL})
|
||||
return
|
||||
}
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{"ok": true, "bj": out, "bj_url": cfg.BJURL})
|
||||
})
|
||||
|
||||
log.Printf("bj-lk (Личный кабинет) слушает %s, bj-server: %s", cfg.Listen, cfg.BJURL)
|
||||
srv := &http.Server{Addr: cfg.Listen, Handler: mux, ReadHeaderTimeout: 5 * time.Second}
|
||||
if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
|
||||
log.Fatal(err)
|
||||
}
|
||||
_ = context.Background
|
||||
}
|
||||
Reference in New Issue
Block a user