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:
zuevav
2026-09-15 19:41:12 +03:00
parent 59fc774f08
commit da12a9ec1c
132 changed files with 29969 additions and 1089 deletions
+66 -21
View File
@@ -47,8 +47,11 @@ func EnsureDocSources(rc *RuntimeConfig) {
}
}
// pdfHrefRe — ищет в HTML href'ы, заканчивающиеся на .pdf (case-insensitive).
var pdfHrefRe = regexp.MustCompile(`(?i)href="([^"]+\.pdf)"`)
// docFileRe — ищет ссылки на документы (.pdf/.docx/.doc/.zip/.xlsx/.xls) в HTML
// И в встроенном JSON. Сайт НРД — SPA (Next.js): ссылки лежат не в href="…",
// а внутри __NEXT_DATA__ как строки "/media/docs/…". Поэтому матчим значение в
// двойных ИЛИ экранированных кавычках, без требования атрибута href.
var docFileRe = regexp.MustCompile(`(?i)(?:href=)?\\?"((?:https?://[^"\\]+|/[^"\\]+)\.(?:pdf|docx?|zip|xlsx?))(?:\?[^"\\]*)?\\?"`)
// noProxyClient — HTTP-клиент, который игнорирует переменные окружения
// HTTPS_PROXY / HTTP_PROXY. Корпоративный прокси zetit блокирует
@@ -84,23 +87,26 @@ func CheckDocSources(ctx context.Context, rc *RuntimeConfig) string {
s.DocSources[i].KnownPDFs = map[string]string{}
}
known := s.DocSources[i].KnownPDFs
fmt.Fprintf(&summary, " найдено %d ссылок на PDF\n", len(pdfs))
fmt.Fprintf(&summary, " найдено %d ссылок на документы\n", len(pdfs))
newlyAdded := 0
for _, pdfURL := range pdfs {
hash, changed := checkPDF(ctx, pdfURL, known)
if !changed {
continue
}
known[pdfURL] = hash
newlyAdded++
localPath, err := downloadPDFToDOC(ctx, pdfURL)
if err != nil {
// НЕ помечаем known — чтобы перекачать при следующей проверке.
fmt.Fprintf(&summary, " ✗ %s: %v\n", pdfURL, err)
continue
}
known[pdfURL] = hash // фиксируем только после успешной загрузки
newlyAdded++
fmt.Fprintf(&summary, " ✓ %s → %s\n", pdfURL, localPath)
// Новость в ленту.
_ = rc.AddNews(NewsItem{
// Новость добавляем в ЛОКАЛЬНЫЙ снапшот s (не rc.AddNews): в конце
// функции s целиком сохраняется через UpdateNews(s), и прямая запись
// в rc была бы затёрта этим снимком.
item := NewsItem{
ID: "doc-" + hash[:12],
At: now,
Kind: "doc-update",
@@ -108,7 +114,18 @@ func CheckDocSources(ctx context.Context, rc *RuntimeConfig) string {
Body: "Источник: " + src.Name + "\nURL: " + pdfURL +
"\nЛокально: " + localPath + "\nSHA-256: " + hash[:16] + "…",
URL: pdfURL,
})
}
// Дедуп по ID + новые сверху.
dup := false
for _, ex := range s.Items {
if ex.ID == item.ID {
dup = true
break
}
}
if !dup {
s.Items = append([]NewsItem{item}, s.Items...)
}
}
s.DocSources[i].LastChecked = now
if newlyAdded > 0 {
@@ -124,8 +141,9 @@ func CheckDocSources(ctx context.Context, rc *RuntimeConfig) string {
return summary.String()
}
// fetchPDFLinks качает HTML-страницу и извлекает все href'ы, заканчивающиеся
// на .pdf. Относительные URL разворачиваются в абсолютные.
// fetchPDFLinks качает страницу и извлекает ссылки на документы
// (.pdf/.docx/.doc/.zip/.xlsx) как из HTML-href, так и из встроенного JSON
// (Next.js SPA). Относительные URL разворачиваются в абсолютные.
func fetchPDFLinks(ctx context.Context, pageURL string) ([]string, error) {
reqCtx, cancel := context.WithTimeout(ctx, 30*time.Second)
defer cancel()
@@ -152,20 +170,23 @@ func fetchPDFLinks(ctx context.Context, pageURL string) ([]string, error) {
if err != nil {
return nil, err
}
matches := pdfHrefRe.FindAllStringSubmatch(string(body), -1)
matches := docFileRe.FindAllStringSubmatch(string(body), -1)
seen := map[string]bool{}
var out []string
for _, m := range matches {
ref, err := url.Parse(m[1])
raw := strings.ReplaceAll(m[1], "\\/", "/") // JSON-экранированные слэши
ref, err := url.Parse(raw)
if err != nil {
continue
}
abs := base.ResolveReference(ref).String()
// Игнорируем «системные» PDF (политика конфиденциальности и т.п.).
// Игнорируем «системные»/брендовые файлы и статику сборки SPA.
low := strings.ToLower(abs)
if strings.Contains(low, "personal_information") ||
strings.Contains(low, "personal-information") ||
strings.Contains(low, "razmeschenie-logotipa") {
strings.Contains(low, "logotipa") || // руководство по логотипу (любое написание)
strings.Contains(low, "logo-moex") ||
strings.Contains(low, "/_next/") {
continue
}
if seen[abs] {
@@ -219,19 +240,22 @@ func downloadPDFToDOC(ctx context.Context, pdfURL string) (string, error) {
return "", err
}
name := filepath.Base(u.Path)
if name == "" || !strings.HasSuffix(strings.ToLower(name), ".pdf") {
ext := strings.ToLower(filepath.Ext(name))
switch ext {
case ".pdf", ".docx", ".doc", ".zip", ".xlsx", ".xls":
default:
return "", errors.New("странное имя файла")
}
docDir := "DOC"
if _, err := os.Stat(docDir); err != nil {
return "", fmt.Errorf("DOC/ не доступен: %w", err)
docDir := docsWritableDir()
if docDir == "" {
return "", errors.New("нет доступного для записи каталога документов")
}
dst := filepath.Join(docDir, name)
// Если файл уже есть — переименуем как backup.
// Если файл уже есть — переименуем как backup (расширение сохраняем).
if _, err := os.Stat(dst); err == nil {
old := filepath.Join(docDir,
strings.TrimSuffix(name, ".pdf")+
"."+time.Now().Format("2006-01-02")+".pdf.bak")
strings.TrimSuffix(name, ext)+
"."+time.Now().Format("2006-01-02")+ext+".bak")
_ = os.Rename(dst, old)
}
@@ -263,6 +287,27 @@ func downloadPDFToDOC(ctx context.Context, pdfURL string) (string, error) {
return dst, nil
}
// docsWritableDir выбирает каталог для скачанных документов НРД. Под systemd
// репозиторий /opt/bj/src/DOC монтируется read-only (ProtectSystem=strict),
// поэтому основной путь — /var/lib/bj/.bj/nsd-docs (в ReadWritePaths). В деве
// (репозиторий доступен на запись) используется DOC/. Возвращает "" если ни
// один путь не записываемый.
func docsWritableDir() string {
for _, d := range []string{"/var/lib/bj/.bj/nsd-docs", "DOC"} {
if err := os.MkdirAll(d, 0o755); err != nil {
continue
}
// Проверяем именно право записи (MkdirAll на существующий RO-каталог не ошибётся).
probe := filepath.Join(d, ".wtest")
if f, err := os.Create(probe); err == nil {
f.Close()
_ = os.Remove(probe)
return d
}
}
return ""
}
// StartDocWatcher запускает горутину, которая раз в сутки проверяет
// DocSources и эмитирует новости. Стартует через 60 сек после Run().
func StartDocWatcher(rc *RuntimeConfig) func() {