docs(files): add unified file client contract (#39)
Release Please / Release Please (push) Skipped
Release / GoReleaser (push) Skipped
Tests / Secret scan (gitleaks) (push) Skipped
Tests / Test (Go 1.25) (push) Skipped
Tests / Test (Go stable) (push) Skipped
Tests / Secret scan (gitleaks) (pull_request) Successful in 4s
Tests / Test (Go 1.25) (pull_request) Successful in 17s
Tests / Test (Go stable) (pull_request) Successful in 1m20s

This commit is contained in:
2026-09-16 17:37:15 +00:00
parent c76ab7267d
commit c59ad645ea
3 changed files with 208 additions and 2 deletions
+195
View File
@@ -0,0 +1,195 @@
---
type: reference
status: current
related:
- README.md
- file_core.go
- file_facade.go
- docs/elasticsearch.md
- docs/community-server-db.md
---
# Unified file client — контракт файловых бэкендов
## Что это
Один файловый клиент на все бэкенды (эпик #34). Модель и интерфейсы —
`file_core.go`. Фасад `FileClient` — `file_facade.go`. Бэкенды:
REST, WebDAV, SQL (PostgreSQL/MySQL), Elasticsearch. Правило одно:
код зовёт `c.Files()` и не знает про транспорт.
## Модель
- `Kind` — `File` (0) или `Folder` (1).
- `Entry` — бэкенд-независимая строка: `ID`, `ParentID`, `Title`, `Kind`,
`Size`, `MIME`, `Created`, `Modified`, `Updated` (сырая строка API),
`Version`, `Provider`, `FilesCount`/`FoldersCount` (папки).
Чего бэкенд не даёт — остаётся в нуле.
- `SearchQuery` — `Text`, `InContent`, `FolderID`, `Extensions`, `Limit`.
- `SearchHit` — `Entry` + `Score`, `Highlight`, `Path`.
## Интерфейсы
`FileStore` — операции с файлами:
```go
type FileStore interface {
Name() string
List(ctx, parentID) ([]Entry, error)
Stat(ctx, id) (Entry, error)
CreateFolder(ctx, parentID, title) (Entry, error)
Upload(ctx, parentID, title, r) (Entry, error)
Download(ctx, id, w) (int64, error)
Move(ctx, ids, parentID) error
Copy(ctx, ids, parentID) error
Rename(ctx, id, title) error
Delete(ctx, ids) error
}
```
`Searcher` — поиск (необязательный):
```go
type Searcher interface {
Search(ctx, q SearchQuery) ([]SearchHit, error)
Name() string
}
```
`TextIndex` (`file_es_text.go`) — свой индекс: `Put`, `Delete`, `Search`,
`Name`. `ESTextIndex` реализует и `Searcher`, и `TextIndex`.
## Бэкенды
| бэкенд | провайдер | файл | что умеет |
|--------|-----------|------|-----------|
| REST | `rest` | `file_rest.go` | read + write, Documents API |
| WebDAV | `dav` | `file_dav.go` | read + write, Documents fileops |
| SQL | `postgres` / `mysql` | `file_pg.go` | **read-only** |
| OnlyOffice ES | `elasticsearch` | `file_es.go` | поиск (имя + контент Office) |
| свой ES-индекс | `es-text` | `file_es_text.go` | поиск + запись (PDF/сканы) |
- REST: `Stat` знает только файлы; папки — через `List`.
- WebDAV: `Move`/`Copy`/`Delete` сперва `Stat`-ят id (папка/файл), потом зовут
fileops.
- SQL: `List`/`Stat`/`Download`/`Search` (по имени). Все write-методы →
`ErrReadOnly`. `Download` идёт в S3/MinIO по layout портала.
- OnlyOffice ES: индекс `files_file`, контент только для docx/xlsx/pptx.
- Свой ES: индекс `oo_docs_text`, контент из `internal/docpipe`, в т.ч.
встроенные PDF-вложения.
## Фасад `FileClient`
`c.Files()` → `*FileClient`. Он же реализует `FileStore`, старый код
компилируется.
- `Read()` — первый зарегистрированный из `readOrder`:
`postgres` → `rest` → `dav`.
- `Write()` — первый из `writeOrder`: `rest` → `dav`. SQL не пишет.
- `Search()` — первый из `searchOrder`: `elasticsearch`. Нет бэкенда →
ошибка (`ONLYOFFICE_ES_URL`).
- `RegisterStore(name, s)` / `RegisterSearcher(name, s)` — добавить бэкенд.
Fallback:
- `List`/`Stat` идут по `readOrder`; переходят к следующему только на
transient-ошибке (429/502/503/504). Иначе ошибка финальная.
- `Download` **без** fallback: часть байтов уже в `w`, второй бэкенд допишет.
- Запись (`CreateFolder`/`Upload`/`Move`/`Copy`/`Rename`/`Delete`) — только
`Write()`, без fallback.
`newFileClient` сам кладёт `rest` и `dav`; ES-поиск — если задан
`ONLYOFFICE_ES_URL`. SQL-стор регистрирует вызывающий: фасад создаётся на
каждый `c.Files()`, регистрируй на том же экземпляре.
```go
f := c.Files()
if pg, err := onlyoffice.NewPGStore(onlyoffice.PGConfigFromEnv()); err == nil {
defer pg.Close()
f.RegisterStore(onlyoffice.ProviderPG, pg)
}
entries, _ := f.List(ctx, "649") // пойдёт в SQL
```
## CLI
```bash
# поиск: --backend oo (индекс OnlyOffice) | own (свой oo_docs_text)
oo search "Rechnung"
oo search "Mahngebühr" --content
oo search "S1021" --content --backend own --folder 634 --limit 50 --json
# наполнение своего индекса (PDF/сканы, idempotent upsert по file id)
oo index folder 634
oo index folder 634 --recursive --exts pdf,png --limit 100
oo index files 3576 3578
oo index folder 634 --dry-run
```
`oo index` флаги: `--recursive`, `--exts` (default `pdf`), `--limit`,
`--workers` (3), `--lang` (`deu+eng`), `--min-chars`, `--work-dir`,
`--backend rest|dav`, `--dry-run`, `--json`.
Библиотека:
```go
idx, _ := onlyoffice.NewESTextIndex(onlyoffice.ESTextConfigFromEnv())
ti := onlyoffice.NewTextIndexer(store, idx) // store = FileStore
res, _ := ti.IndexFolder(ctx, "634", onlyoffice.IndexOptions{Recursive: true})
```
## Env (только имена)
| env | default | кто читает |
|-----|---------|------------|
| `ONLYOFFICE_ES_URL` | — | ES (оба индекса), обязателен |
| `ONLYOFFICE_ES_INDEX` | `files_file` | индекс OnlyOffice |
| `ONLYOFFICE_ES_TEXT_INDEX` | `oo_docs_text` | свой индекс |
| `ONLYOFFICE_TENANT` | пусто | фильтр `tenantId` |
| `ONLYOFFICE_DSN` | — | SQL DSN (MySQL/PostgreSQL) |
| `ONLYOFFICE_PG_DRIVER` | auto | `postgres` / `mysql` |
| `ONLYOFFICE_PG_TENANT` | `ONLYOFFICE_TENANT` | SQL tenant |
| `ONLYOFFICE_PG_HOST` `_PORT` `_USER` `_PASSWORD` `_DBNAME` `_SSLMODE` | — | DSN по частям |
| `MINIO_ENDPOINT` `MINIO_BUCKET` `MINIO_ACCESS_KEY` `MINIO_SECRET_KEY` | — | download SQL-стора |
| `OO_URL` `OO_USER` `OO_PASS` | — | CLI-алиасы |
Имена — в [`.env.example`](../.env.example). Секретов в репо нет.
## Ограничения
- OnlyOffice ES: контент только Office-форматов. PDF — только по имени.
Встроенные вложения PDF сервер не индексирует.
- Свой индекс `oo_docs_text`: покрывает PDF/сканы и вложения (pdfdetach), но
наполняется вручную (`oo index`) и идемпотентен. Фильтр `folder` — id папки,
не путь. Дубли дают несколько строк — дедуп на потребителе.
- SQL: read-only. `InContent` игнорируется (только имя). Download — через
MinIO-схему, не HTTP.
- ES: без auth, слушает localhost внутри VM — нужен SSH-туннель
(см. [elasticsearch.md](elasticsearch.md)).
- `oo index` качает каждый файл и для сканов гоняет OCR — медленно; отсюда
`--limit` и `--exts`. Нужен `pdfdetach` (poppler); без него — только тело PDF.
## Как добавить бэкенд
1. Файл `file_<name>.go`. Реализуй `FileStore` (`Name` + 9 методов). Нужен
поиск — добавь `Searcher`; нужна запись своего индекса — `TextIndex`.
2. Добавь const провайдера рядом с `ProviderREST`/`ProviderDAV`.
3. Зарегистрируй: в `newFileClient` или снаружи через
`RegisterStore`/`RegisterSearcher`.
4. Внеси имя в `readOrder` / `writeOrder` / `searchOrder`.
5. Есть CLI-команда — добавь значение в `--backend`.
6. Тесты: unit (чистые builders/парсеры, без сети) + интеграционный
(`//go:build integration`, skip без кред).
## Тесты
```bash
go test ./... # unit, без сети
go test -tags=integration ./... # live (креды в .env)
go test ./ -run 'FileStore|Facade|ESText|PG'
```
## См. также
- [elasticsearch.md](elasticsearch.md) — индекс OnlyOffice и свой `oo_docs_text`.
- [community-server-db.md](community-server-db.md) — SQL-стор и схема БД.