added AGENTS.md and docs in root dir
Android CI / Build and Test (push) Failing after 13m25s
iOS CI / Build and Test SwiftUI App (push) Canceled after 0s
Build Unsigned iOS and macOS Apps / Build Unsigned iOS IPA (push) Canceled after 0s
Build Unsigned iOS and macOS Apps / Build macOS ZIP (push) Canceled after 0s

This commit is contained in:
wt committed 2026-09-08 13:36:43 +07:00
1 parent 2f891c9503
commit da9e17f45f
7 files changed
+728 -7

No files matched your search

+85
View File
@@ -0,0 +1,85 @@
# Архитектура Balance (обзор всей системы)
Этот документ описывает, как связаны между собой три подпроекта.
Детальная архитектура каждой платформы — в `docs/ARCHITECTURE.md`
соответствующего подпроекта.
## Общая схема
```text
┌─────────────┐ ┌─────────────┐ ┌─────────────┐
│ iOS клиент │ │ macOS клиент│ │watchOS клиент│ Apple: SwiftUI + SwiftData
└──────┬──────┘ └──────┬──────┘ └──────┬──────┘ (Balance/)
│ │ │
└────────┬────────┴────────┬────────┘
│ │
HTTPS (или HTTP только для localhost)
│ │
┌────────┴────────┐ │
│ Android клиент │ │ Kotlin + Jetpack Compose + Room
│ (BalanceAndroid)│ │ (BalanceAndroid/)
└────────┬────────┘ │
│ │
▼ ▼
┌───────────────────────────────┐
│ BalanceServer │ Go 1.25 + SQLite
│ ┌───────────┬───────────────┐ │ (BalanceServer/)
│ │ HTTP API │ Web-клиент │ │
│ │ /v1/* │ (embedded) │ │
│ └───────────┴───────────────┘ │
└───────────────────────────────┘
```
Каждый нативный клиент (iOS/macOS/watchOS/Android) хранит полную копию
данных локально (SwiftData или Room) и работает офлайн. Сервер —
единственная точка правды при конфликте между устройствами одного
аккаунта: он хранит append-only лог изменений с курсором и применяет
last-write-wins по времени изменения записи (`updatedAt`/`syncUpdatedAt`).
Web-клиент не является отдельным подпроектом — это статические файлы,
встроенные (embed) в Go-бинарник сервера, использующие тот же HTTP API,
что и нативные клиенты, но с cookie-based сессией вместо Bearer-токена.
## Компоненты сервера
- **HTTP API** (`internal/httpapi/`) — auth-эндпоинты и `/v1/sync`.
- **auth** (`internal/auth/`) — пароли (Argon2id), JWT access/refresh
токены.
- **database** (`internal/database/`) — SQLite-хранилище, схема,
индексы.
- **config** (`internal/config/`) — переменные окружения.
- **web** (`internal/httpapi/web.go`) — раздача встроенного
web-приложения.
Подробнее — [`BalanceServer/docs/ARCHITECTURE.md`](../BalanceServer/docs/ARCHITECTURE.md).
## Компоненты Apple-клиента
- **Models** — SwiftData `@Model` сущности.
- **Utilities/FinanceCalculations** — единый источник правил расчёта
баланса и аналитики.
- **Shared/ServerSync** — HTTP-клиент, авторизация, sync orchestration,
Keychain.
- **Views** — общий SwiftUI UI; `BalanceMac/` и `BalanceWatch/` —
платформенно-специфичные точки входа и экраны.
Подробнее — [`Balance/docs/ARCHITECTURE.md`](../Balance/docs/ARCHITECTURE.md).
## Компоненты Android-клиента
- **data/** — Room entities, DAO, `FinanceMath` (та же роль, что и
`FinanceCalculations` в Apple-клиенте).
- **sync/** — `ServerApi` (HTTP/auth), `ServerPreferences` (URL/cursor),
`SecureSessionStore` (Android Keystore), `SyncRepository`
(orchestration), `SyncWorker` (периодический фон).
- **ui/** — Jetpack Compose экраны, состояние из `MainViewModel`.
Подробнее — [`BalanceAndroid/docs/ARCHITECTURE.md`](../BalanceAndroid/docs/ARCHITECTURE.md).
## Принцип согласованности между платформами
Три клиента реализуют одну и ту же доменную модель и один и тот же
sync-протокол независимо друг от друга, на разных стеках. Это
осознанный компромисс (нативный UX на каждой платформе) с обратной
стороной: любое изменение общего контракта нужно вносить согласованно
в трёх местах. См. `docs/SYNC_PROTOCOL.md` и `docs/DATA_MODEL.md`.
+108
View File
@@ -0,0 +1,108 @@
# Сквозная модель данных
Три платформы реализуют одну и ту же доменную модель независимо, на
своём стеке хранения. Этот документ сопоставляет поля между ними.
Полные описания — [`Balance/docs/DATA_MODEL.md`](../Balance/docs/DATA_MODEL.md),
[`BalanceAndroid/docs/DATA_MODEL.md`](../BalanceAndroid/docs/DATA_MODEL.md),
[`BalanceServer/docs/DATA_MODEL.md`](../BalanceServer/docs/DATA_MODEL.md).
## Транзакция
| Логическое поле | Apple (`FinanceTransaction`) | Android (`TransactionEntity`) | Сервер |
| --- | --- | --- | --- |
| Идентификатор | `id: UUID` | `id: String` (UUID) | stable ID |
| Сумма | `amount: Double` | `amount: Double` | amount |
| Дата операции | `date: Date` | `date: Long` (epoch millis) | date |
| Заметка | `note: String` | `note: String` | note |
| Имя категории (snapshot) | `categoryName: String` | `categoryName: String` | category presentation data |
| Иконка категории | `categoryIcon: String` (SF Symbol) | `categoryIcon: String` | — |
| Emoji категории | `categoryEmoji: String` | `categoryEmoji: String` | — |
| Цвет категории | `categoryColorName: String` | `categoryColorName: String` | — |
| Корректировка баланса | `isBalanceAdjustment: Bool` | `isBalanceAdjustment: Boolean` | balance-adjustment flag |
| Тип операции | `kindRawValue: String` (`income`/`expense`) | `kindRawValue: String` | transaction kind |
| Версия/время изменения | `syncUpdatedAt: Date` | `updatedAt: Long` | `updatedAt` |
| Владелец | сессия аккаунта | сессия аккаунта | принадлежность пользователю |
**Категория хранится snapshot'ом**, а не ссылкой, на всех платформах:
это позволяет не терять отображение старой операции при удалении
категории и синхронизировать запись без relationship. Из-за этого
переименование пользовательской категории должно явно обновлять уже
существующие транзакции и бюджеты — и Apple-, и Android-клиент это
делают.
## Пользовательская категория
| Логическое поле | Apple (`CustomCategory`) | Android (`categories`) | Сервер |
| --- | --- | --- | --- |
| Идентификатор | `id: UUID` | `id` | stable ID |
| Имя | `name: String` | `name` | name |
| Иконка | `icon: String` | `icon` | icon/emoji |
| Emoji | `emoji: String` | `emoji` | — |
| Цвет | `colorName: String` | `colorName` | color |
| Тип | `kindRawValue: String` | `kindRawValue` | operation kind |
| Создание | `createdAt: Date` | `createdAt` | creation metadata |
| Версия | `syncUpdatedAt: Date` | `updatedAt` | update metadata |
Имя пользовательской категории уникально без учёта регистра/диакритики
относительно системных и других пользовательских категорий (проверяется
на клиентах). Встроенные системные категории (Продукты, Транспорт,
Зарплата и т.д.) не персистятся как отдельные записи ни на одной
платформе — они закодированы в клиентском коде.
## Месячный бюджет
| Логическое поле | Apple (`MonthlyBudget`) | Android (`budgets`) | Сервер |
| --- | --- | --- | --- |
| Идентификатор | `id: UUID` | `id` | stable ID |
| Имя категории | `categoryName: String` | `categoryName` | category information |
| Иконка/emoji категории | `categoryIcon`, `categoryEmoji` | `categoryIcon`, `categoryEmoji` | — |
| Лимит | `limit: Double` | `limitAmount: Double` | monthly limit |
| Начало месяца | `monthStart: Date` | `monthStart: Long` | month start |
| Версия | `syncUpdatedAt: Date` | `updatedAt: Long` | `updatedAt` |
Бюджет идентифицируется собственным UUID, но связывается с категорией
по сохранённому имени (`categoryName`), а не по ссылке.
## Tombstone / deletion journal
| Логическое поле | Apple (`SyncTombstone`) | Android (`deletion_journal`) | Сервер (change record) |
| --- | --- | --- | --- |
| Идентификатор записи | `id: UUID` | `id = entity:recordId` | record ID |
| Тип сущности | `entity: String` | `entity` | entity |
| ID удалённой записи | `recordID: String` | `recordId` | id |
| Время | `updatedAt: Date` | `updatedAt: Long` | `updatedAt` |
| Флаг удаления | подразумевается (`deleted: true` в payload) | подразумевается | `deleted: true` |
Поддерживаемые значения `entity` одинаковы на всех платформах:
`transaction`, `category`, `budget`.
## Финансовые инварианты (одинаковы на Apple и Android)
1. `amount` не может быть отрицательным через обычный UI.
2. Общий баланс = `sum(income.amount) − sum(expense.amount)`; записи с
`isBalanceAdjustment == true` **учитываются** в этой сумме.
3. Периодическая аналитика **исключает** balance adjustments и записи
вне выбранного периода.
4. Расходы группируются по `categoryName` (не по ID категории).
5. `syncUpdatedAt`/`updatedAt` обязано меняться при каждом изменении
синхронизируемой сущности.
6. Удаление синхронизируемой сущности обязано создать tombstone —
прямой `DELETE`/`delete()` без tombstone запрещён.
Источники правды по расчётам:
`Balance/Balance/Utilities/FinanceCalculations.swift` (Apple) и
`BalanceAndroid/.../data/FinanceMath.kt` (Android). При изменении
правил на одной платформе перенеси изменение на другую.
## Инварианты на стороне сервера
1. Record ID стабилен между синхронизациями.
2. Запись принадлежит ровно одному пользователю; клиент не может
читать/изменять чужие записи.
3. `sequence` (серверный порядок изменений) не должен уменьшаться.
4. Повторная доставка одного и того же изменения не должна повреждать
данные (идемпотентность).
5. Удаление обязано быть видимым клиенту через следующий pull.
6. Все timestamps хранятся в UTC.
7. Enum/строковые значения (`kindRawValue` и т.п.) совместимы между
iOS и Android — не переименовывать без периода совместимости.
+106
View File
@@ -0,0 +1,106 @@
# Разработка (все платформы)
Сводная шпаргалка по сборке, запуску и проверке всех трёх подпроектов.
Подробности — в `docs/DEVELOPMENT.md` каждого подпроекта.
## Требования по платформам
| Подпроект | Инструменты | Минимальные версии |
| --- | --- | --- |
| `Balance/` | Xcode, Swift | Xcode 16+, iOS 17+, macOS 14+, watchOS 10+ |
| `BalanceAndroid/` | Android Studio / Gradle | AGP 8.13, Kotlin 2.3.21, compileSdk 36, minSdk 26, Java 17 |
| `BalanceServer/` | Go | Go 1.25.0+ |
## Локальный запуск всей системы
Порядок, удобный для end-to-end проверки sync между платформами:
```bash
# 1. Поднять сервер
cd BalanceServer
cp .env.example .env
# задать BALANCE_JWT_SECRET (см. README.md)
go mod tidy
go run ./cmd/balance-server
# сервер слушает http://localhost:8080
# 2. Открыть Apple-проект и указать http://localhost:8080 в Settings → Server account
cd ../Balance
open Balance.xcodeproj
# 3. Собрать и запустить Android-приложение
cd ../BalanceAndroid
./gradlew assembleDebug
# на эмуляторе сервер локального хоста доступен как http://10.0.2.2:8080
```
Для физического iPhone/Android-устройства `localhost` указывает на само
устройство, а не на машину разработчика — используйте сетевой адрес
или HTTPS reverse proxy (см. `docs/SECURITY.md`).
## Сборка и тесты по отдельности
### Apple (`Balance/`)
```bash
xcodebuild -project Balance.xcodeproj -list
xcodebuild -project Balance.xcodeproj -scheme Balance \
-destination 'generic/platform=iOS' build
xcodebuild -project Balance.xcodeproj -scheme Balance \
-destination 'platform=iOS Simulator,name=<available iPhone>' test
```
Схемы: `Balance` (iOS), `BalanceMac`, `BalanceWatch`. Без
`BalanceCloudKitEnabled=YES` приложение работает в local-mode
(SwiftData без CloudKit) — рекомендуемый режим для разработки.
### Android (`BalanceAndroid/`)
```bash
./gradlew clean test assembleDebug
```
Тесты — `app/src/test/FinanceMathTest`. `local.properties` с `sdk.dir`
не должен попадать в Git.
### Server (`BalanceServer/`)
```bash
go mod download
go test ./...
go test -race ./...
go vet ./...
go build ./...
```
Docker-путь — `docker compose up -d --build` (см.
[`BalanceServer/README.md`](../BalanceServer/README.md)).
## Что проверять при изменении общего контракта
Если PR меняет sync API, модель данных или финансовые расчёты — прогони
проверки во всех трёх подпроектах, а не только в изменённом:
```text
old iOS client + new server
new iOS client + server
Android client + server
concurrent sync с двух устройств одного аккаунта
login/refresh после истечения access token
delete → pull на другом устройстве
```
## Типовые проблемы синхронизации (одинаковый чек-лист для обеих платформ)
1. Проверь server URL в настройках клиента.
2. Проверь правило HTTPS/localhost (см. `docs/SECURITY.md`).
3. Проверь авторизацию/сессию (login, не истёк ли refresh).
4. Проверь дату последней синхронизации и сохранённый cursor.
5. Проверь совместимость версии клиента и `BalanceServer`.
6. Если данные не удаляются на другом устройстве — убедись, что
удаление прошло через tombstone-путь
(`modelContext.deleteForSync` на Apple,
`FinanceDao.deleteTransaction/deleteCategory/deleteBudget` на
Android), а не через прямой `delete()`.
+95
View File
@@ -0,0 +1,95 @@
# Безопасность (сквозные требования)
Этот документ собирает требования безопасности, которые применяются
ко **всем** подпроектам одновременно. Платформенно-специфичные детали —
в [`BalanceServer/docs/SECURITY.md`](../BalanceServer/docs/SECURITY.md),
`Balance/AGENTS.md` (раздел «Безопасность») и
`BalanceAndroid/AGENTS.md` (раздел «Security»).
## Хранение секретов на клиентах
| | Apple | Android |
| --- | --- | --- |
| Access/refresh токены | Keychain (service `BalanceServer`) | зашифрованное хранилище через Android Keystore (AES/GCM), `SecureSessionStore` |
| Несекретные настройки (URL сервера, cursor, sync metadata) | `UserDefaults` | `ServerPreferences` |
| Запрещено | пароль в `UserDefaults`, SwiftData или файлах | токены в обычных `SharedPreferences` |
Ни один клиент не должен хранить пароль пользователя после
аутентификации — только access/refresh токены.
## Хранение секретов на сервере
- Пароли — только Argon2id-хеши, plaintext никогда не сохраняется.
- Refresh-токены хранятся как SHA-256 хеши, не в открытом виде.
- `BALANCE_JWT_SECRET` должен быть случайной строкой не короче 32
символов; его смена инвалидирует access-токены (refresh-сессии
остаются валидны и получают новый access-токен при следующем
refresh).
## Транспорт
- Для удалённого (не-localhost) сервера все клиенты обязаны
использовать HTTPS.
- HTTP разрешён клиентам **только** для `localhost`, `127.0.0.1` и
`::1` — это относится и к Apple-, и к Android-клиенту.
- Production-деплой сервера должен работать через HTTPS/TLS
(например, через reverse proxy — Caddy/nginx), см.
[`BalanceServer/README.md#https`](../BalanceServer/README.md#https).
## Логирование
Запрещено логировать на любой из трёх платформ:
- пароли;
- access/refresh токены;
- заголовок `Authorization` целиком;
- полные request bodies с финансовыми данными без явной необходимости.
Безопасно логировать (на сервере): request ID, endpoint, status code,
latency, размер batch, неконфиденциальные идентификаторы записей.
## Авторизация на сервере
Каждая sync-мутация обязана проверять ownership по authenticated user
из токена/сессии. Никогда не доверяй `userId`, пришедшему в теле
запроса от клиента, если identity уже известна из
Authorization-заголовка или cookie-сессии:
```text
Authorization header / session cookie
↓
authenticated user
↓
server-side ownership check
↓
requested entity
```
## Валидация входных данных (сервер)
Проверяются: UUID/ID, enum-значения, `amount`, timestamps, длины строк,
размер batch, размер JSON, границы cursor/sequence.
## Защита от злоупотреблений (production-деплой сервера)
- rate limiting для auth-эндпоинтов (встроенный лимитер — 30
auth-запросов на IP в минуту; для более сильного публичного
rate limiting используйте reverse proxy);
- разумные ограничения на размер тела запроса;
- таймауты и graceful shutdown;
- ограничение подключений к БД;
- мониторинг.
## Секреты в репозитории
Ни в одном из трёх подпроектов не должны появляться:
- production URL серверов;
- реальные `BALANCE_JWT_SECRET` или иные секреты;
- закоммиченные `.env`, keystore-файлы, сертификаты;
- захардкоженные тестовые credentials, которые могут быть спутаны с
боевыми.
`.env.example` в `BalanceServer/` и `local.properties.example` в
`BalanceAndroid/` — единственные допустимые «примерные» файлы с
конфигурацией; реальные значения в них не хранятся.
+88
View File
@@ -0,0 +1,88 @@
# Единый протокол синхронизации
Все клиенты (iOS, macOS, watchOS, Android, web) синхронизируются с
`BalanceServer` через один и тот же HTTP(S) API и один и тот же набор
инвариантов. Это сводка контракта; детальные описания на стороне
сервера — [`BalanceServer/docs/API.md`](../BalanceServer/docs/API.md) и
[`BalanceServer/docs/SYNC.md`](../BalanceServer/docs/SYNC.md).
Клиентские реализации — [`Balance/docs/SYNC.md`](../Balance/docs/SYNC.md)
и [`BalanceAndroid/docs/SYNC.md`](../BalanceAndroid/docs/SYNC.md).
## Эндпоинты
| Метод | Путь | Назначение |
| --- | --- | --- |
| `GET` | `/health` | Health check |
| `POST` | `/v1/auth/register` | Создание аккаунта |
| `POST` | `/v1/auth/login` | Вход, выдача access/refresh токенов |
| `POST` | `/v1/auth/refresh` | Ротация токенов |
| `POST` | `/v1/auth/logout` | Отзыв refresh-токена |
| `GET` | `/v1/me` | Текущий аккаунт |
| `POST` | `/v1/sync` | Push и pull изменений |
Нативные клиенты используют `Authorization: Bearer <access-token>`.
Web-клиент использует `HttpOnly`/`SameSite=Strict` cookie-сессию —
это единственное отличие транспорта, сама sync-семантика идентична.
## Основные инварианты
1. **Cursor** — каждый клиент хранит курсор последнего успешно
применённого изменения. Курсор сохраняется только после успешного
применения соответствующего batch, не раньше.
2. **Sequence** — сервер поддерживает глобальный монотонный порядок
изменений в change stream; порядок не должен уменьшаться и должен
быть однозначным при конкурентных запросах.
3. **Push** — клиент отправляет свои изменения (`POST /v1/sync`);
сервер валидирует, проверяет владение записью (ownership) и
добавляет изменение в change stream. Повторный push (retry) не
должен создавать вторую логическую запись из одного и того же
client-change (идемпотентность).
4. **Pull** — клиент запрашивает изменения после своего курсора;
сервер никогда не возвращает чужие данные (изоляция по аккаунту) и
поддерживает постраничный pull с продолжением от нового курсора.
5. **Conflict resolution** — last-write-wins по `updatedAt`
(`syncUpdatedAt` на Apple, `updatedAt` на Android/сервере). Сервер —
единственный источник правды в этом сравнении, поэтому не должен
произвольно перезаписывать `updatedAt` при каждом чтении.
6. **Удаления (tombstones)** — удаление сущности не является простым
`DELETE`; оно создаёт запись изменения вида
`{ "entity": ..., "id": ..., "deleted": true, "updatedAt": ... }`,
которая проходит через тот же change stream, что и обычные
изменения. На Apple это `SyncTombstone`/`modelContext.deleteForSync`,
на Android — `deletion_journal` через DAO-методы `deleteTransaction` /
`deleteCategory` / `deleteBudget`.
7. **Batch** — Apple-клиент отправляет батчи до 100 изменений и может
делить крупные payload'ы; сервер не должен предполагать, что все
изменения аккаунта приходят одним запросом. Android должен
оставаться совместимым с этим же батчингом.
8. **Reset/full sync** — если клиент теряет курсор, сервер должен
поддерживать предусмотренный API механизм полного pull или новый
baseline-курсор. Неизвестный/просроченный курсор нельзя молча
трактовать как актуальный.
## Синхронизируемые сущности
- **Транзакция** (доход/расход, сумма, дата, заметка, snapshot
категории);
- **Пользовательская категория** (имя, иконка/emoji, цвет);
- **Месячный бюджет** (категория, месяц, лимит);
- **Tombstone / deletion journal** запись для удалений.
Подробное пополевое сопоставление — [`docs/DATA_MODEL.md`](DATA_MODEL.md).
## Что нельзя менять без координации
Любое из следующих изменений — breaking change для всех клиентов
одновременно, даже если каждый подпроект по отдельности компилируется
и проходит собственные тесты:
- имена/типы JSON-полей в payload'ах `/v1/sync` и auth-эндпоинтов;
- HTTP-методы или пути эндпоинтов;
- семантика курсора/sequence;
- правило разрешения конфликтов (last-write-wins по `updatedAt`);
- формат tombstone-записи.
При таком изменении обнови одновременно: `BalanceServer` (API +
хендлеры + `docs/API.md`/`docs/SYNC.md`), `Balance/Shared/ServerSync.swift`
(+ `Balance/docs/SYNC.md`), `BalanceAndroid/.../sync/*`
(+ `BalanceAndroid/docs/SYNC.md`) и этот файл.