feat(admin): блок «Новости» + doc-watcher + авто-уведомления о сертификатах УЦ

Новый раздел /admin/news — лента событий системы (окна техработ НРД,
обновления документации, переустановка сертификатов УЦ). Каждая
новость со временем, типом (maintenance/feature/doc-update/system/
manual), опциональным окном действительности (ValidFrom..ValidTo) и
ссылкой на источник. Лента не очищается — служит журналом для аудита.

На дашборде /admin/ — компактный блок «📢 Новости»: показывает максимум
3 актуальных события (активных сейчас или с окном, начинающимся в
ближайшие 7 дней; в остатке — самые свежие). Окна техработ при
наступлении становятся жёлтыми (border-left, ValidFrom..ValidTo).

В ленте можно добавлять новости вручную (форма на /admin/news), скрывать
(soft-delete через Dismissed). Дедуп по ID.

Doc-watcher: горутина в bj-server, раз в сутки качает страницы НРД
(дефолтные источники — moex-most, программы НРД, криптосервис), парсит
HTML на ссылки .pdf, скачивает новые версии в DOC/ (со старыми
переименовывая в .YYYY-MM-DD.pdf.bak для аудита), и публикует
новость «Обновлена документация: <file>». Sha256-дедуп — пере-импорта
неизменённого PDF не будет.

Cacerts.go: FetchCACertificates теперь принимает *RuntimeConfig и при
успешной переустановке сертификата эмитирует NewsItem «Обновлён
сертификат УЦ: <CN>». Если сертификат истекает в ближайшие 14 дней —
отдельная новость-предупреждение. Это закрывает запрос «получает в авто
режиме и предупреждает об этом» из обсуждения.

SeedDefaultNews публикует при старте bj-server две известные новости:
- TEST3 недоступен 18.05.2026 — 22.05.2026 (НРД письмо НРД-И-2026-8452)
- Робот-автотест MOEX МОСТ доступен на TEST3 с 12.05.2026

Скачаны три свежие инструкции с nsd.ru/services/novye-servisy/moex-most-dlya-m2m/:
- DOC/instruktsiya-po-testirovaniyu-s-robotom.pdf (новая, 12.05.2026)
- DOC/instruktsiya-dlya-osuschestvleniya-obmena-soobscheniyami-...-fizicheskim-litsom-samomu-sebe.pdf (новая, 12.05.2026)
- DOC/servis-most-m2m.pdf (актуальная общая инструкция)

Mastered tasks: #46, #47, #48.
This commit is contained in:
fontvielle
2026-05-14 16:26:41 +03:00
parent f1e05c0ca3
commit 93f3ec240c
12 changed files with 734 additions and 11 deletions
+47 -5
View File
@@ -21,7 +21,7 @@ var templatesFS embed.FS
type admin struct {
home, claims, claim, status, setup *template.Template
help, helpDatabase, helpLK, helpCryptoPro, helpSystems *template.Template
wizard *template.Template
wizard, news *template.Template
}
// templateFuncs — функции, доступные внутри шаблонов. Главная задача —
@@ -31,6 +31,7 @@ var templateFuncs = template.FuncMap{
"ru": russianText,
"ruState": russianState,
"ruOutcome": russianOutcome,
"now": time.Now,
}
// russianState переводит технический FSM-state в человекочитаемый
@@ -126,10 +127,14 @@ func newAdmin() (*admin, error) {
if err != nil {
return nil, fmt.Errorf("parse admin_wizard: %w", err)
}
news, err := parse("admin_news.html")
if err != nil {
return nil, fmt.Errorf("parse admin_news: %w", err)
}
return &admin{
home: home, claims: claims, claim: claim, status: status, setup: setup,
help: help, helpDatabase: helpDB, helpLK: helpLK, helpCryptoPro: helpCP, helpSystems: helpSys,
wizard: wizard,
wizard: wizard, news: news,
}, nil
}
@@ -151,6 +156,7 @@ type homeData struct {
Failed int
}
Recent []ClaimView
News []NewsItem // top-3 активных или свежих новостей
}
// claimsData — данные журнала.
@@ -175,7 +181,7 @@ type statusData struct {
// RegisterAdmin вешает HTML-маршруты /admin/* на mux. Возвращает admin
// со всеми загруженными шаблонами — вызывающий может прокинуть его в
// registerSetup для добавления вкладки «Настройка».
func RegisterAdmin(mux *http.ServeMux, svc *Service, getOpts func() CheckOptions) (*admin, error) {
func RegisterAdmin(mux *http.ServeMux, svc *Service, rc *RuntimeConfig, getOpts func() CheckOptions) (*admin, error) {
a, err := newAdmin()
if err != nil {
return nil, err
@@ -185,7 +191,7 @@ func RegisterAdmin(mux *http.ServeMux, svc *Service, getOpts func() CheckOptions
p := strings.TrimPrefix(r.URL.Path, "/admin/")
switch {
case p == "" || p == "index" || p == "home":
a.renderHome(w, r, svc, getOpts())
a.renderHome(w, r, svc, rc, getOpts())
case p == "claims":
a.renderClaims(w, r, svc)
case strings.HasPrefix(p, "claims/"):
@@ -213,7 +219,7 @@ func RegisterAdmin(mux *http.ServeMux, svc *Service, getOpts func() CheckOptions
return a, nil
}
func (a *admin) renderHome(w http.ResponseWriter, r *http.Request, svc *Service, opts CheckOptions) {
func (a *admin) renderHome(w http.ResponseWriter, r *http.Request, svc *Service, rc *RuntimeConfig, opts CheckOptions) {
ctx := r.Context()
status := CheckAll(ctx, opts)
recent, err := svc.ListClaims(ctx, m2mcore.Filter{Limit: 10})
@@ -225,6 +231,7 @@ func (a *admin) renderHome(w http.ResponseWriter, r *http.Request, svc *Service,
page: nowPage("Дашборд", "home"),
Status: status,
Recent: recent.Items,
News: topNews(rc.Snapshot().News.Items, 3),
}
full, err := svc.ListClaims(ctx, m2mcore.Filter{Limit: 200})
if err == nil {
@@ -279,3 +286,38 @@ func render(w http.ResponseWriter, t *template.Template, data any) {
func nowPage(title, active string) page {
return page{Title: title, Active: active, Now: time.Now().Format("02.01.2006 15:04:05")}
}
// topNews отбирает максимум N новостей: сначала те, что активны прямо сейчас
// (по ValidFrom..ValidTo), потом просто свежие. Скрытые (Dismissed) — мимо.
func topNews(items []NewsItem, n int) []NewsItem {
now := time.Now()
var active, rest []NewsItem
for _, it := range items {
if it.Dismissed {
continue
}
isActive := !it.ValidFrom.IsZero() && !it.ValidTo.IsZero() &&
now.After(it.ValidFrom) && now.Before(it.ValidTo)
// «Будущие» окна с ValidFrom в будущем тоже считаем актуальными
// (предупредить заранее).
isUpcoming := !it.ValidFrom.IsZero() && now.Before(it.ValidFrom) &&
it.ValidFrom.Sub(now) < 7*24*time.Hour
if isActive || isUpcoming {
active = append(active, it)
} else {
rest = append(rest, it)
}
}
out := active
if len(out) < n {
need := n - len(out)
if need > len(rest) {
need = len(rest)
}
out = append(out, rest[:need]...)
}
if len(out) > n {
out = out[:n]
}
return out
}