feat: add BVK exchange library

This commit is contained in:
Георгий
2026-07-12 20:44:44 +03:00
parent 3a1c524eb2
commit 0ff0905a9c
15 changed files with 627 additions and 0 deletions

4
.gitignore vendored Normal file
View File

@@ -0,0 +1,4 @@
.DS_Store
coverage.out
dist/
tmp/

43
README.md Normal file
View File

@@ -0,0 +1,43 @@
# Mila
Mila is a small Go library for BVK media-complex content exchange.
It implements the HTTP protocol where BVK pushes the currently displayed
content state to the media complex:
```text
GET /set-content?route=<R>&str=<S>&tpl_name=<T>
GET /get-content
```
The library intentionally does not know anything about snapshots, route
progress, geolocation, frontend state, or media playback rules. It only parses,
validates, stores, logs, and returns BVK content state.
## Usage
```go
store := mila.NewFileStore("/srv/persistence/client_logs/bvk_content.json")
handler := mila.NewHandler(mila.DefaultConfig(), store,
mila.WithOnSet(func(state mila.ContentState) {
// Application-specific integration goes here.
}),
)
srv := &http.Server{
Addr: "0.0.0.0:13280",
Handler: handler,
}
```
## State
```json
{
"route": "3",
"str": "Гостиный двор - Gostiny dvor",
"tpl_name": "Гостиный двор",
"updated_at": "2026-07-12T12:00:00Z"
}
```

8
errors.go Normal file
View File

@@ -0,0 +1,8 @@
package mila
import "errors"
var (
ErrNotReady = errors.New("mila: state is not ready")
ErrInvalidData = errors.New("mila: invalid content state")
)

3
go.mod Normal file
View File

@@ -0,0 +1,3 @@
module gitea.unprism.ru/KRBL/mila
go 1.24

141
handler.go Normal file
View File

@@ -0,0 +1,141 @@
package mila
import (
"context"
"encoding/json"
"errors"
"log/slog"
"net/http"
"strings"
"time"
)
type Handler struct {
cfg Config
store Store
logger *slog.Logger
onSet func(ContentState)
}
func NewHandler(cfg Config, store Store, opts ...Option) *Handler {
if store == nil {
store = NewMemoryStore()
}
h := &Handler{
cfg: normalizeConfig(cfg),
store: store,
logger: slog.Default(),
}
for _, opt := range opts {
opt(h)
}
return h
}
func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
start := time.Now()
status := http.StatusOK
defer func() {
h.logger.Info("mila request",
slog.String("method", r.Method),
slog.String("path", r.URL.Path),
slog.String("remote_addr", r.RemoteAddr),
slog.Int("status", status),
slog.Duration("duration", time.Since(start)),
)
}()
if r.Method != http.MethodGet {
status = http.StatusMethodNotAllowed
w.Header().Set("Allow", http.MethodGet)
writeError(w, status, "method not allowed")
return
}
switch cleanPath(r.URL.Path) {
case "/set-content":
status = h.handleSetContent(w, r)
case "/get-content":
status = h.handleGetContent(w, r)
default:
status = http.StatusNotFound
writeError(w, status, "path not found")
}
}
func (h *Handler) handleSetContent(w http.ResponseWriter, r *http.Request) int {
q := r.URL.Query()
state := ContentState{
Route: strings.TrimSpace(q.Get("route")),
Text: q.Get("str"),
Template: strings.TrimSpace(q.Get("tpl_name")),
UpdatedAt: time.Now().UTC(),
}
if err := Validate(h.cfg, state); err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return http.StatusBadRequest
}
if err := h.store.Set(r.Context(), state); err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return http.StatusInternalServerError
}
if h.onSet != nil {
h.onSet(state)
}
writeJSON(w, http.StatusOK, map[string]string{"status": "OK"})
return http.StatusOK
}
func (h *Handler) handleGetContent(w http.ResponseWriter, r *http.Request) int {
state, err := h.store.Get(r.Context())
if err != nil {
if errors.Is(err, ErrNotReady) {
writeError(w, http.StatusServiceUnavailable, "content state is not ready")
return http.StatusServiceUnavailable
}
if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) {
writeError(w, http.StatusServiceUnavailable, err.Error())
return http.StatusServiceUnavailable
}
writeError(w, http.StatusInternalServerError, err.Error())
return http.StatusInternalServerError
}
writeJSON(w, http.StatusOK, protocolContentResponse{
Route: state.Route,
Text: state.Text,
Template: state.Template,
})
return http.StatusOK
}
type protocolContentResponse struct {
Route string `json:"route"`
Text string `json:"str"`
Template string `json:"tpl_name"`
}
func cleanPath(path string) string {
if path == "" {
return "/"
}
if len(path) > 1 {
path = strings.TrimRight(path, "/")
}
return path
}
func writeJSON(w http.ResponseWriter, status int, payload any) {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.WriteHeader(status)
_ = json.NewEncoder(w).Encode(payload)
}
func writeError(w http.ResponseWriter, status int, msg string) {
writeJSON(w, status, map[string]string{"error": msg})
}

66
handler_test.go Normal file
View File

@@ -0,0 +1,66 @@
package mila
import (
"encoding/json"
"net/http"
"net/http/httptest"
"net/url"
"testing"
)
func TestHandlerSetAndGetContent(t *testing.T) {
handler := NewHandler(DefaultConfig(), NewMemoryStore())
params := url.Values{}
params.Set("route", "3")
params.Set("str", "Гостиный двор - Gostiny dvor")
params.Set("tpl_name", "Гостиный двор")
req := httptest.NewRequest(http.MethodGet, "/set-content?"+params.Encode(), nil)
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("set-content status=%d body=%s", rec.Code, rec.Body.String())
}
req = httptest.NewRequest(http.MethodGet, "/get-content", nil)
rec = httptest.NewRecorder()
handler.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("get-content status=%d body=%s", rec.Code, rec.Body.String())
}
var state ContentState
if err := json.NewDecoder(rec.Body).Decode(&state); err != nil {
t.Fatalf("decode response: %v", err)
}
if state.Route != "3" || state.Text != "Гостиный двор - Gostiny dvor" || state.Template != "Гостиный двор" {
t.Fatalf("unexpected state: %+v", state)
}
}
func TestHandlerGetBeforeSetReturns503(t *testing.T) {
handler := NewHandler(DefaultConfig(), NewMemoryStore())
req := httptest.NewRequest(http.MethodGet, "/get-content", nil)
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, req)
if rec.Code != http.StatusServiceUnavailable {
t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String())
}
}
func TestHandlerRejectsInvalidSetContent(t *testing.T) {
handler := NewHandler(DefaultConfig(), NewMemoryStore())
req := httptest.NewRequest(http.MethodGet, "/set-content?route=1234&str=ok&tpl_name=tpl", nil)
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, req)
if rec.Code != http.StatusBadRequest {
t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String())
}
}

19
logger.go Normal file
View File

@@ -0,0 +1,19 @@
package mila
import "log/slog"
type Option func(*Handler)
func WithLogger(logger *slog.Logger) Option {
return func(h *Handler) {
if logger != nil {
h.logger = logger
}
}
}
func WithOnSet(fn func(ContentState)) Option {
return func(h *Handler) {
h.onSet = fn
}
}

63
protocol.md Normal file
View File

@@ -0,0 +1,63 @@
# BVK Exchange Protocol
Based on ротокол_обменаедиакомплекса_и_БВК.pdf`.
## Role
- BVK is the HTTP client.
- Media complex is the HTTP server.
- Default address from the document: `172.16.16.2:13280`.
## Set Content
```text
GET /set-content?route=<R>&str=<S>&tpl_name=<T>
```
Parameters are URL-encoded.
| Parameter | Meaning |
| --- | --- |
| `route` | Route number, 1 to 3 characters |
| `str` | Ticker/running text |
| `tpl_name` | Media content template name |
Current library defaults:
| Field | Default limit |
| --- | ---: |
| `route` | 3 characters |
| `str` | 200 characters |
| `tpl_name` | 200 characters |
The exact text/template limits must be confirmed with the integrator because the
PDF text layer is ambiguous for the original value.
## Get Content
```text
GET /get-content
```
Response:
```json
{
"route": "<R>",
"str": "<S>",
"tpl_name": "<T>"
}
```
Mila keeps `updated_at` internally, but the HTTP response is strict and exposes
only the fields defined by the protocol.
## Status Codes
| Code | Meaning |
| ---: | --- |
| 200 | OK |
| 400 | Bad request / validation error |
| 404 | Path not found |
| 500 | Internal server error |
| 503 | State is not ready or dependency unavailable |

15
state.go Normal file
View File

@@ -0,0 +1,15 @@
package mila
import "time"
// ContentState is the current display state received from BVK.
type ContentState struct {
Route string `json:"route"`
Text string `json:"str"`
Template string `json:"tpl_name"`
UpdatedAt time.Time `json:"updated_at,omitempty"`
}
func (s ContentState) IsZero() bool {
return s.Route == "" && s.Text == "" && s.Template == ""
}

8
store.go Normal file
View File

@@ -0,0 +1,8 @@
package mila
import "context"
type Store interface {
Get(ctx context.Context) (ContentState, error)
Set(ctx context.Context, state ContentState) error
}

84
store_file.go Normal file
View File

@@ -0,0 +1,84 @@
package mila
import (
"context"
"encoding/json"
"errors"
"os"
"path/filepath"
"sync"
)
type FileStore struct {
path string
mu sync.Mutex
mem *MemoryStore
}
func NewFileStore(path string) *FileStore {
return &FileStore{
path: path,
mem: NewMemoryStore(),
}
}
func (s *FileStore) Get(ctx context.Context) (ContentState, error) {
if state, err := s.mem.Get(ctx); err == nil {
return state, nil
}
s.mu.Lock()
defer s.mu.Unlock()
data, err := os.ReadFile(s.path)
if err != nil {
if errors.Is(err, os.ErrNotExist) {
return ContentState{}, ErrNotReady
}
return ContentState{}, err
}
var state ContentState
if err := json.Unmarshal(data, &state); err != nil {
return ContentState{}, err
}
if state.IsZero() {
return ContentState{}, ErrNotReady
}
if err := s.mem.Set(ctx, state); err != nil {
return ContentState{}, err
}
return state, nil
}
func (s *FileStore) Set(ctx context.Context, state ContentState) error {
select {
case <-ctx.Done():
return ctx.Err()
default:
}
s.mu.Lock()
defer s.mu.Unlock()
if err := os.MkdirAll(filepath.Dir(s.path), 0o755); err != nil {
return err
}
data, err := json.MarshalIndent(state, "", " ")
if err != nil {
return err
}
tmp := s.path + ".tmp"
if err := os.WriteFile(tmp, data, 0o644); err != nil {
return err
}
if err := os.Rename(tmp, s.path); err != nil {
_ = os.Remove(tmp)
return err
}
return s.mem.Set(ctx, state)
}

47
store_memory.go Normal file
View File

@@ -0,0 +1,47 @@
package mila
import (
"context"
"sync"
)
type MemoryStore struct {
mu sync.RWMutex
state ContentState
ready bool
}
func NewMemoryStore() *MemoryStore {
return &MemoryStore{}
}
func (s *MemoryStore) Get(ctx context.Context) (ContentState, error) {
select {
case <-ctx.Done():
return ContentState{}, ctx.Err()
default:
}
s.mu.RLock()
defer s.mu.RUnlock()
if !s.ready {
return ContentState{}, ErrNotReady
}
return s.state, nil
}
func (s *MemoryStore) Set(ctx context.Context, state ContentState) error {
select {
case <-ctx.Done():
return ctx.Err()
default:
}
s.mu.Lock()
defer s.mu.Unlock()
s.state = state
s.ready = true
return nil
}

88
validation.go Normal file
View File

@@ -0,0 +1,88 @@
package mila
import (
"errors"
"fmt"
"strings"
"unicode/utf8"
)
type Config struct {
MaxRouteLen int
MaxTextLen int
MaxTemplateLen int
}
func DefaultConfig() Config {
return Config{
MaxRouteLen: 3,
MaxTextLen: 200,
MaxTemplateLen: 200,
}
}
type ValidationError struct {
Field string `json:"field"`
Msg string `json:"message"`
}
func (e ValidationError) Error() string {
return fmt.Sprintf("%s: %s", e.Field, e.Msg)
}
func Validate(cfg Config, state ContentState) error {
cfg = normalizeConfig(cfg)
state.Route = strings.TrimSpace(state.Route)
state.Template = strings.TrimSpace(state.Template)
if state.Route == "" {
return ValidationError{Field: "route", Msg: "is required"}
}
if runeLen(state.Route) > cfg.MaxRouteLen {
return ValidationError{Field: "route", Msg: fmt.Sprintf("must be at most %d characters", cfg.MaxRouteLen)}
}
if !utf8.ValidString(state.Route) {
return ValidationError{Field: "route", Msg: "must be valid UTF-8"}
}
if !utf8.ValidString(state.Text) {
return ValidationError{Field: "str", Msg: "must be valid UTF-8"}
}
if runeLen(state.Text) > cfg.MaxTextLen {
return ValidationError{Field: "str", Msg: fmt.Sprintf("must be at most %d characters", cfg.MaxTextLen)}
}
if state.Template == "" {
return ValidationError{Field: "tpl_name", Msg: "is required"}
}
if !utf8.ValidString(state.Template) {
return ValidationError{Field: "tpl_name", Msg: "must be valid UTF-8"}
}
if runeLen(state.Template) > cfg.MaxTemplateLen {
return ValidationError{Field: "tpl_name", Msg: fmt.Sprintf("must be at most %d characters", cfg.MaxTemplateLen)}
}
return nil
}
func IsValidationError(err error) bool {
var target ValidationError
return errors.As(err, &target)
}
func normalizeConfig(cfg Config) Config {
def := DefaultConfig()
if cfg.MaxRouteLen <= 0 {
cfg.MaxRouteLen = def.MaxRouteLen
}
if cfg.MaxTextLen <= 0 {
cfg.MaxTextLen = def.MaxTextLen
}
if cfg.MaxTemplateLen <= 0 {
cfg.MaxTemplateLen = def.MaxTemplateLen
}
return cfg
}
func runeLen(s string) int {
return utf8.RuneCountInString(s)
}

38
validation_test.go Normal file
View File

@@ -0,0 +1,38 @@
package mila
import "testing"
func TestValidateAcceptsValidUTF8State(t *testing.T) {
err := Validate(DefaultConfig(), ContentState{
Route: "3",
Text: "Гостиный двор - Gostiny dvor",
Template: "Гостиный двор",
})
if err != nil {
t.Fatalf("Validate returned error: %v", err)
}
}
func TestValidateRejectsTooLongRoute(t *testing.T) {
err := Validate(DefaultConfig(), ContentState{
Route: "1234",
Text: "ok",
Template: "tpl",
})
if err == nil {
t.Fatal("expected validation error")
}
if !IsValidationError(err) {
t.Fatalf("expected ValidationError, got %T", err)
}
}
func TestValidateRejectsMissingTemplate(t *testing.T) {
err := Validate(DefaultConfig(), ContentState{
Route: "3",
Text: "ok",
})
if err == nil {
t.Fatal("expected validation error")
}
}