diff --git a/.env.example b/.env.example index 1dcb292..923fc96 100644 --- a/.env.example +++ b/.env.example @@ -26,55 +26,44 @@ ERYAO_REDIS__PORT=6379 ERYAO_REDIS__DB=0 ############ -# MySQL 数据库配置 +# Worker 队列分组配置 +############ +# agent: 常规异步任务 +ERYAO_WORKER__GROUPS__AGENT__CONCURRENCY=2 + +############ +# Supabase 配置 +############ +ERYAO_SUPABASE__PUBLIC_URL=https://your-project.supabase.co +ERYAO_SUPABASE__ANON_KEY= +ERYAO_SUPABASE__SERVICE_ROLE_KEY= +ERYAO_SUPABASE__JWT_SECRET= +ERYAO_SUPABASE__JWT_ALGORITHM=HS256 + +############ +# PostgreSQL 数据库配置(Supabase 本地开发) ############ ERYAO_DATABASE__HOST=localhost -ERYAO_DATABASE__PORT=3306 +ERYAO_DATABASE__PORT=5432 ERYAO_DATABASE__NAME=eryao -ERYAO_DATABASE__USER=root -ERYAO_DATABASE__PASSWORD=your_mysql_password_here +ERYAO_DATABASE__USER=postgres +ERYAO_DATABASE__PASSWORD=change-me-strong-password ############ -# 阿里云短信配置 +# Storage 配置 ############ -ERYAO_ALIYUN_SMS__ACCESS_KEY_ID=your_aliyun_access_key_id -ERYAO_ALIYUN_SMS__ACCESS_KEY_SECRET=your_aliyun_access_key_secret -ERYAO_ALIYUN_SMS__SIGN_NAME=your_sign_name -ERYAO_ALIYUN_SMS__TEMPLATE_CODE=your_template_code +ERYAO_STORAGE__ATTACHMENT__BUCKET=agent-attachments +ERYAO_STORAGE__AVATAR__BUCKET=avatars +ERYAO_STORAGE__SIGNED_URL_TTL_SECONDS=600 +ERYAO_STORAGE__ATTACHMENT__MAX_SIZE_MB=20 +ERYAO_STORAGE__AVATAR__MAX_SIZE_MB=2 +ERYAO_STORAGE__RETENTION_DAYS=30 ############ -# 阿里云内容安全配置 +# LLM API KEY ############ -ERYAO_ALIYUN_CONTENT_SECURITY__ACCESS_KEY_ID=your_aliyun_access_key_id -ERYAO_ALIYUN_CONTENT_SECURITY__ACCESS_KEY_SECRET=your_aliyun_access_key_secret - -############ -# 支付宝配置 -############ -ERYAO_ALIPAY__APP_ID=your_app_id -ERYAO_ALIPAY__MERCHANT_ID=your_merchant_id -ERYAO_ALIPAY__PUBLIC_KEY=your_alipay_public_key -ERYAO_ALIPAY__PRIVATE_KEY=your_alipay_private_key -ERYAO_ALIPAY__NOTIFY_URL=https://your-domain.com/api/payment/notify -ERYAO_ALIPAY__SANDBOX=false - -############ -# DeepSeek API 配置 -############ -ERYAO_DEEPSEEK__API_KEY=your_deepseek_api_key - -############ -# 认证配置 -############ -ERYAO_AUTH__TOKEN_EXPIRATION_DAYS=7 -ERYAO_AUTH__TOKEN_REFRESH_THRESHOLD_HOURS=2 - -############ -# 验证码配置 -############ -ERYAO_VERIFICATION__CODE_LENGTH=6 -ERYAO_VERIFICATION__EXPIRATION_MINUTES=5 -ERYAO_VERIFICATION__TEST_MODE=false +ERYAO_LLM__PROVIDER_KEYS__DASHSCOPE= +ERYAO_LLM__PROVIDER_KEYS__DEEPSEEK= ############ # 敏感词配置 @@ -82,14 +71,13 @@ ERYAO_VERIFICATION__TEST_MODE=false ERYAO_SENSITIVE_WORD__USE_ALIYUN=true ERYAO_SENSITIVE_WORD__FALLBACK_TO_LOCAL=true -############ -# App 版本更新配置 -############ -ERYAO_APP_VERSION__MANIFEST_PATH=deploy/static/releases/manifest.json -ERYAO_APP_VERSION__RELEASE_PATH_PREFIX=releases -ERYAO_APP_VERSION__DOWNLOAD_BASE_URL= - ############ # CORS 配置 ############ ERYAO_CORS__ALLOW_ORIGINS=["http://localhost", "http://localhost:3000"] + +############ +# Test相关 +############ +ERYAO_TEST__PHONE=8613812345678 +ERYAO_TEST__PASSWORD=Test@123456 diff --git a/.gitignore b/.gitignore index 315154d..56dd8ca 100644 --- a/.gitignore +++ b/.gitignore @@ -1,71 +1,198 @@ -# ============================================ -# Environment & Secrets -# ============================================ -.env -.env.local -.env.*.local -!.env.example - -# ============================================ -# Python -# ============================================ +# Byte-compiled / optimized / DLL files __pycache__/ *.py[codz] *$py.class + +# C extensions *.so + +# Distribution / packaging .Python build/ +develop-eggs/ dist/ +downloads/ +eggs/ +.eggs/ +/lib/ +/lib64/ +!apps/lib/ +parts/ +sdist/ +var/ +wheels/ +share/python-wheels/ *.egg-info/ +.installed.cfg *.egg MANIFEST + +# PyInstaller +*.manifest +*.spec + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports +htmlcov/ .tox/ .nox/ .coverage .coverage.* +.cache +nosetests.xml +coverage.xml +*.cover +*.py.cover +.hypothesis/ .pytest_cache/ -htmlcov/ +cover/ + +# Translations +*.mo +*.pot + +# Django stuff: +*.log +local_settings.py +db.sqlite3 +db.sqlite3-journal + +# Flask stuff: +instance/ +.webassets-cache + +# Scrapy stuff: +.scrapy + +# Sphinx documentation +docs/_build/ + +# PyBuilder +.pybuilder/ +target/ + +# Jupyter Notebook +.ipynb_checkpoints + +# IPython +profile_default/ +ipython_config.py + +# pyenv +.python-version + +# pipenv +# Pipfile.lock + +# UV +# uv.lock + +# poetry +# poetry.lock +# poetry.toml + +# pdm +# pdm.lock +# pdm.toml +.pdm-python +.pdm-build/ + +# pixi +# pixi.lock +.pixi + +# PEP 582 +__pypackages__/ + +# Celery stuff +celerybeat-schedule +celerybeat.pid + +# Redis +*.rdb +*.aof +*.pid + +# RabbitMQ +mnesia/ +rabbitmq/ +rabbitmq-data/ + +# ActiveMQ +activemq-data/ + +# SageMath parsed files +*.sage.py + +# Environments .env .envrc .venv +env/ venv/ ENV/ env.bak/ venv.bak/ + +# Spyder project settings +.spyderproject +.spyproject + +# Rope project settings +.ropeproject + +# mkdocs documentation +/site + +# mypy .mypy_cache/ .dmypy.json dmypy.json -.pyre/ -.pytype/ -*.log -db.sqlite3 -# ============================================ -# Flutter -# ============================================ -/bin/cache/ -/bin/internal/ -/dev/benchmarks/ -/dev/bots/ -/dev/docs/ -/dev/integration_tests/**/xcuserdata -/dev/integration_tests/**/Pods -/packages/flutter/coverage/ -version -analysis_benchmark.json -.packages.generated +# Pyre type checker +.pyre/ + +# pytype static type analyzer +.pytype/ + +# Cython debug symbols +cython_debug/ + +# Ruff stuff: +.ruff_cache/ + +# PyPI configuration file +.pypirc + +# Marimo +marimo/_static/ +marimo/_lsp/ +__marimo__/ + +# Streamlit +.streamlit/secrets.toml + +# Flutter/Dart/Pub related **/doc/api/ .dart_tool/ -.flutter-plugins -.flutter-plugins-dependencies +.dart_tool/flutter_build/ **/generated_plugin_registrant.dart .packages .pub-preload-cache/ .pub/ build/ flutter_*.png +linked_*.ds +unlinked.ds +unlinked_spec.ds -# Android +# IDE +.idea/ + +# Android related **/android/**/gradle-wrapper.jar .gradle/ **/android/captures/ @@ -75,8 +202,9 @@ flutter_*.png **/android/**/GeneratedPluginRegistrant.java **/android/key.properties *.jks +**/android/**/*.iml -# iOS/XCode +# iOS/XCode related **/ios/**/*.mode1v3 **/ios/**/*.mode2v3 **/ios/**/*.moved-aside @@ -102,8 +230,12 @@ flutter_*.png **/ios/Flutter/app.flx **/ios/Flutter/app.zip **/ios/Flutter/flutter_assets/ +**/ios/Flutter/flutter_export_environment.sh **/ios/ServiceDefinitions.json **/ios/Runner/GeneratedPluginRegistrant.* +**/ios/Podfile.lock +**/ios/Runner.xcodeproj/ +**/ios/Runner.xcworkspace/ # macOS **/Flutter/ephemeral/ @@ -112,78 +244,92 @@ flutter_*.png **/macos/Flutter/ephemeral **/xcuserdata/ +# Linux +**/linux/flutter/generated_plugin_registrant.cc +**/linux/flutter/generated_plugin_registrant.h +**/linux/flutter/generated_plugins.cmake + +# Windows +**/windows/flutter/generated_plugin_registrant.cc +**/windows/flutter/generated_plugin_registrant.h +**/windows/flutter/generated_plugins.cmake +**/windows/runner/ + +# Linux +**/linux/flutter/generated_plugin_registrant.cc +**/linux/flutter/generated_plugin_registrant.h +**/linux/flutter/generated_plugins.cmake + # Coverage coverage/ -# ============================================ -# Kotlin / Gradle / Android -# ============================================ +# Symbols +app.*.symbols + +# Exceptions to above rules. +!**/ios/**/default.mode1v3 +!**/ios/**/default.mode2v3 +!**/ios/**/default.pbxuser +!**/ios/**/default.perspectivev3 +!/packages/flutter_tools/test/data/dart_dependencies_test/**/.packages +!/dev/ci/**/Gemfile.lock + +# Local environment files +infra/local/env/*.env +configs/env/*.env +infra/cloud/volcano/env/*.env +!infra/local/env/*.env.example +!configs/env/*.env.example +!infra/cloud/volcano/env/*.env.example +.env.local +.env.*.local +.env.cloud +.env.*.cloud +deploy/.env.prod + +# Misc *.class -*.log *.lock +*.swp .buildlog/ .history -build/ -app/build/ -login-service/build/ -.gradle/ -.idea/ -!.idea/codeStyles/ -*.iml -out/ -*.apk -*.aab -*.dex +/logs/ +backend/logs/ +backend/data/analytics/ +*.tar.gz +*.tar +# Docker volumes (local data) +docker/supabase/volumes/db/data/ +infra/docker/volumes/db/data/ +infra/docker/supabase/volumes/db/data/ +infra/docker/supabase/volumes/storage/ -# ============================================ -# Node.js -# ============================================ -node_modules/ -npm-debug.log* -yarn-debug.log* -yarn-error.log* -package-lock.json -yarn.lock +# OpenCode local config +# .opencode/ is now tracked - see .opencode/.gitignore for exclusions -# ============================================ -# Java / Spring Boot -# ============================================ -target/ -*.class -*.jar -*.war -*.ear -hs_err_pid* -spring-boot-*.jar +# Agents and skills +.agents/ -# ============================================ -# IDE -# ============================================ -.vscode/ -*.swp -*.swo -*~ +# Local git worktrees +.worktrees/ +worktrees/ + +# Runtime temp files +.tmp/ + +# macOS system files .DS_Store -Thumbs.db -*.sublime-* -.idea/ -*.iml -atlassian-ide-plugin.xml -.project -.classpath -.settings/ +**/.DS_Store -# ============================================ -# Misc -# ============================================ -*.pid -*.seed -*.pid.lock -*.rdb -*.aof -*.pid +# Deploy releases (APK files only, keep manifest.json) +deploy/static/releases/*.apk +deploy/static/releases/*.ipa -# ============================================ -# Local folders -# ============================================ +# Superset +.superset/ + +# Local agents and skills +.agents/ + +# Old legacy code old/ diff --git a/.opencode/commands/doc-update.md b/.opencode/commands/doc-update.md new file mode 100644 index 0000000..79cb251 --- /dev/null +++ b/.opencode/commands/doc-update.md @@ -0,0 +1,77 @@ +--- +description: 审查并更新 docs/protocols,确保与当前代码实现一致 +--- + +你现在要执行一次“协议文档一致性审查与更新”,目标是让 `docs/protocols/` 成为项目协议与数据格式的最新事实来源,并与当前代码实现保持一致。 + +## 执行目标 + +1. 审查 `docs/protocols/` 下所有协议文档是否过期、缺失或与实现不一致。 +2. 在发现差异时,优先更新协议文档(而不是先改代码),明确兼容策略。 +3. 输出结构化审查结果:发现的问题、已更新内容、仍待确认项。 + +## 约束 + +- 审查范围优先限定在:`docs/protocols/**` 与本次协议相关代码(`backend/**`、`apps/**`)。 +- 不做无关重构,不改动与协议无关模块。 +- 禁止“吞错式”描述:若不确定,明确标记为待确认,不要假设正确。 +- 若涉及破坏性变更,必须在文档中写明迁移与回滚策略。 + +## 步骤 + +1. **建立协议清单** + - 列出 `docs/protocols/` 中所有文档。 + - 为每份文档提取:涉及的接口、事件、字段、枚举、状态码、错误结构。 + +2. **建立实现映射** + - 在 `backend/**`、`apps/**` 中定位对应实现与调用点。 + - 对每个协议项建立“文档 -> 实现”映射(文件路径 + 关键符号/接口名)。 + +3. **逐项比对并分级** + - 比对以下维度: + - 请求/响应结构与字段可选性 + - 字段命名、类型、默认值、约束 + - 错误码/错误体格式 + - 版本号、兼容说明、废弃说明 + - 给每个差异打级别: + - CRITICAL:会导致客户端/服务端不兼容 + - HIGH:行为偏差明显,容易引发线上错误 + - MEDIUM:文档缺失或描述不完整 + - LOW:措辞、示例、格式问题 + +4. **更新文档(优先)** + - 先更新 `docs/protocols/**`,使其反映当前真实实现。 + - 每处更新都要补充“兼容策略”: + - `backward-compatible`(向后兼容)或 + - `requires-migration`(需要迁移) + - 如为 `requires-migration`,补充迁移步骤与回滚注意事项。 + +5. **一致性复核** + - 复查所有变更是否与实现一致。 + - 如仓库有协议相关测试/校验脚本,执行最小必要验证。 + +## 输出格式(必须) + +1. **Protocol Audit Scope** + - 本次审查的文档列表 + - 对应实现文件映射 + +2. **Findings** + - 按 CRITICAL/HIGH/MEDIUM/LOW 分组列出差异 + - 每条包含:文档位置、实现位置、差异说明、影响范围 + +3. **Doc Updates Applied** + - 列出已更新的文档与关键修改点 + - 标注每项兼容策略(`backward-compatible` / `requires-migration`) + +4. **Open Questions** + - 仍需产品/后端/前端确认的点 + +5. **Verification** + - 列出执行的验证命令与结果(通过/失败) + +## 完成标准 + +- `docs/protocols/` 覆盖当前实现的真实协议。 +- 所有发现的关键差异(CRITICAL/HIGH)要么已修正文档,要么被明确记录为阻塞项。 +- 输出报告完整,后续协作者可据此继续推进。 diff --git a/.opencode/opencode.json.old b/.opencode/opencode.json.old new file mode 100644 index 0000000..8cd9d3c --- /dev/null +++ b/.opencode/opencode.json.old @@ -0,0 +1,20 @@ +{ + "$schema": "https://opencode.ai/config.json", + "mcp": { + "supabase": { + "type": "local", + "enabled": true, + "command": [ + "npx", + "-y", + "@aliyun-rds/supabase-mcp-server", + "--supabase-url", + "http://47.112.66.83", + "--supabase-anon-key", + "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJvbGUiOiJhbm9uIiwiaWF0IjoxNzczMDI3NDE5LCJleHAiOjEzMjgzNjY3NDE5fQ.NVXDla5_nYPdcJk_81fc3k1UrnNTrNne_trMqt6Hg4g", + "--supabase-service-role-key", + "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJvbGUiOiJzZXJ2aWNlX3JvbGUiLCJpYXQiOjE3NzMwMjc0MTksImV4cCI6MTMyODM2Njc0MTl9.RzQBia-3QcjupsHnqaxgDWB7wnY9R7Ms9R8pMokyvLY" + ] + } + } +} diff --git a/AGENTS.md b/AGENTS.md index 65e86d2..2b294e7 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -37,3 +37,7 @@ Do not place backend/frontend implementation details here. - Update protocol docs before changing data/API/UI contracts. - Document compatibility strategy (backward-compatible vs migration). - Keep frontend/backend implementations aligned with documented protocol. + +## Database Access + +When viewing data in the database, use `supabase mcp` tools (`supabase_execute_sql`, `supabase_list_tables`, etc.) instead of direct queries or other methods. diff --git a/apps/.gitignore b/apps/.gitignore new file mode 100644 index 0000000..3820a95 --- /dev/null +++ b/apps/.gitignore @@ -0,0 +1,45 @@ +# Miscellaneous +*.class +*.log +*.pyc +*.swp +.DS_Store +.atom/ +.build/ +.buildlog/ +.history +.svn/ +.swiftpm/ +migrate_working_dir/ + +# IntelliJ related +*.iml +*.ipr +*.iws +.idea/ + +# The .vscode folder contains launch configuration and tasks you configure in +# VS Code which you may wish to be included in version control, so this line +# is commented out by default. +#.vscode/ + +# Flutter/Dart/Pub related +**/doc/api/ +**/ios/Flutter/.last_build_id +.dart_tool/ +.flutter-plugins-dependencies +.pub-cache/ +.pub/ +/build/ +/coverage/ + +# Symbolication related +app.*.symbols + +# Obfuscation related +app.*.map.json + +# Android Studio will place build artifacts here +/android/app/debug +/android/app/profile +/android/app/release diff --git a/apps/.metadata b/apps/.metadata new file mode 100644 index 0000000..e8cf1e4 --- /dev/null +++ b/apps/.metadata @@ -0,0 +1,33 @@ +# This file tracks properties of this Flutter project. +# Used by Flutter tool to assess capabilities and perform upgrades etc. +# +# This file should be version controlled and should not be manually edited. + +version: + revision: "3b62efc2a3da49882f43c372e0bc53daef7295a6" + channel: "stable" + +project_type: app + +# Tracks metadata for the flutter migrate command +migration: + platforms: + - platform: root + create_revision: 3b62efc2a3da49882f43c372e0bc53daef7295a6 + base_revision: 3b62efc2a3da49882f43c372e0bc53daef7295a6 + - platform: android + create_revision: 3b62efc2a3da49882f43c372e0bc53daef7295a6 + base_revision: 3b62efc2a3da49882f43c372e0bc53daef7295a6 + - platform: ios + create_revision: 3b62efc2a3da49882f43c372e0bc53daef7295a6 + base_revision: 3b62efc2a3da49882f43c372e0bc53daef7295a6 + + # User provided section + + # List of Local paths (relative to this file) that should be + # ignored by the migrate tool. + # + # Files that are not part of the templates will be ignored by default. + unmanaged_files: + - 'lib/main.dart' + - 'ios/Runner.xcodeproj/project.pbxproj' diff --git a/apps/AGENTS.md b/apps/AGENTS.md new file mode 100644 index 0000000..f0f65c4 --- /dev/null +++ b/apps/AGENTS.md @@ -0,0 +1,201 @@ +# Apps Domain Rules + +This file governs `apps/**` (Flutter). Keep rules strict, short, and reusable. + +## Scope & Precedence + +- Inherits root `AGENTS.md` and workspace runtime rules. +- If rules conflict, apply the stricter one. +- Visual language source of truth: `apps/rules/visual_design_language.md`. + +## Flutter Directory Contract (Must) + +- `apps/lib` only allows these second-level directories: `app/`, `core/`, `data/`, `features/`, `shared/`, `l10n/`. +- `apps/lib/main.dart` is the only allowed root entry file. +- Do not add new second-level directories under `apps/lib` without explicit approval. + +## Module Responsibilities (Must) + +- `app/`: app bootstrap, DI wiring, global lifecycle orchestration, router composition. +- `core/`: cross-feature business primitives/protocols/orchestrators (no feature-specific page logic). +- `data/`: shared infrastructure only (cache/network/storage/adapters), not feature business repositories/models. +- `features/`: user-facing bounded feature modules with clear product ownership. +- `shared/`: reusable UI widgets and presentation helpers without feature business orchestration. +- Cross-cutting capabilities (e.g. notification orchestration, UI schema protocol) must live in `core/` + `shared/`, not under `features/`. + +## Placement Rules (Must) + +- Put code in `features/` only when it belongs to one bounded product capability/screen flow. +- Put code in `core/` when it is cross-feature protocol, policy, or orchestration that does not belong to one feature. +- Put reusable UI renderers in `shared/widgets/`; they must not contain feature-only business orchestration. +- In feature data layers, use semantic subfolders: `data/apis/`, `data/repositories/`, `data/services/`, `data/models/`. +- Avoid deep redundant nesting like `models//...`; prefer flat by concern. + +## Shared Data Layer Boundary (Must) + +- Do not place feature business repositories/models under `apps/lib/data/`. +- Feature business repositories/models must live under each feature's `data/` tree. +- `apps/lib/data/` is only for infrastructure abstractions and implementations (cache/network/storage), reusable by features. + +## UI Design System (Must) + +- **Semantic colors**: always use `Theme.of(context).colorScheme.*` (primary, surface, error, etc.). Never hardcode hex or `Colors.*`. +- **Brand palette colors** (event presets, avatar colors, Eisenhower matrix quadrants): use `Theme.of(context).extension()!.*`. +- **Spacing / Radius**: use `AppSpacing` / `AppRadius` from `design_tokens.dart`. No hardcoded values. +- `AppTheme.light` / `AppTheme.dark` provide complete `ColorScheme` (light + dark). `MaterialApp` wires them via `theme:` / `darkTheme:`. +- If a semantic slot is missing from `ColorScheme`, add it to `AppTheme` — do not bypass `colorScheme` with hardcoded values. + +## Reuse & Composition (Must) + +- Prefer `apps/lib/shared/widgets/` before adding new components. +- Extract repeated page structures/components; do not duplicate sibling-page scaffolds. +- Detail page top-right actions must use shared action-menu components. +- Destructive confirmations must use project-consistent shared surfaces. + +## Interaction & Feedback (Must) + +- User feedback: `Toast` / `AppBanner` only. +- Loading indicators: `AppLoadingIndicator` only. +- Form pages should default to keyboard-overlay behavior to avoid full-page layout jumps. + +## Interaction & Feedback (Must) + +## Agent Chat Protocol (Must) + +- Agent chat must follow AG-UI over SSE. +- Lifecycle events are mandatory: `RUN_STARTED` and exactly one of `RUN_FINISHED` or `RUN_ERROR`. +- Current default text delivery is finalized `TEXT_MESSAGE_END` payloads; do not require token-level `TEXT_MESSAGE_CONTENT` unless backend protocol explicitly enables it. + +## HTTP Error Parse Contract (Must) + +- Frontend must parse backend errors as RFC7807: `type/title/status/detail/instance` + extension `code/params`. +- Error code registry single source of truth: `docs/protocols/common/http-error-codes.md`. +- Frontend mapping must be based on documented `code` only (`code -> l10n key`), not inferred from `detail` text. +- Any new/changed code requires protocol doc update first, then frontend mapping update. +- Unknown code fallback order: status-generic localized message -> safe generic localized message. + +## High-Risk Modules (Must) + +### Auth + +- `AuthBloc` is the single source of truth. +- 401 invalidation must go through global callback chain; no feature-level token clearing or direct login navigation. + +### Home Message Viewport + +- Home message auto-scroll/anchor restore must be event-driven. +- Preserve viewport during history prepend and when user is reading above bottom. + +### Cache / Repository + +- Reads/writes that affect consistency must go through repository layer. +- Cache keys and invalidation policy belong to repository, not UI/Bloc. +- Shared cache infrastructure must live under `apps/lib/data/cache/`; feature modules must not duplicate low-level cache store logic. +- Shared cache infrastructure (`apps/lib/data/cache/`) must remain domain-agnostic: do not import `features/**` or business model DTOs there. +- Domain object serialization/deserialization belongs to repository/feature layer via local mappers/codecs; do not centralize feature-specific codecs in shared cache layer. +- Shared cache layer may only encode/decode primitives, collections, and cache metadata wrappers. +- Cache strategy default is `SWR + TTL + invalidation/reload`. +- Local partial cache patching is allowed only for simple single-entity updates with clear rollback paths; complex cross-list/cross-feature states must invalidate and refetch. +- Feature TTL policy must be defined in each feature repository; do not add centralized feature TTL registries in shared cache infra. +- Runtime cache is hybrid (`memory + local persistent`) managed by DI singletons; do not create per-screen/per-widget cache store instances. +- Cross-feature data access must go through app-level facade/usecase boundaries; do not import another feature's data implementation directly from UI/Bloc. +- Repository instances should be resolved from DI singletons to reuse cache and avoid per-feature re-creation. + +### Reminder / Notification Rewrite Boundary + +- Reminder/notification data-interaction logic is under rewrite. Do not reintroduce local-notification scheduling/callback execution paths in `apps/lib/data/services/`. +- During rewrite, keep protocol/orchestration in `core/notification/**` and reusable rendering in `shared/widgets/notification/**`. + +## Testing Policy + +- Prioritize tests for model parsing, service logic, and high-regression interaction flows. +- Simple static UI changes may skip tests. +- Auth/Home/Cache changes must include targeted regression tests. + +## Logging Conventions (Must) + +### Logger Setup + +```dart +import 'core/logging/logger.dart'; + +class SomeBloc extends Cubit { + final Logger _logger = getLogger('features..'); +} +``` + +### Log Level Policy + +| Level | When to Use | Noise Level | +|-------|-------------|-------------| +| **error** | All exceptions and failures - MUST log every error site | Required, never skip | +| **warning** | Degraded behavior, retry, fallback, malformed data | Minimal, only when action taken | +| **info** | Key business events (login, logout, send message) | Minimal, only milestone events | +| **debug** | Detailed flow tracing (only in debug builds) | High, avoid in release | + +### Error Logging Requirements + +**Every try-catch that handles an exception MUST log it:** +```dart +try { + await _repository.someOperation(); +} catch (e, stackTrace) { + _logger.error( + message: 'Operation failed: $operationName', + error: e, + stackTrace: stackTrace, + extra: {'context': 'relevant_data'}, + ); + // handle error +} +``` + +### Info Logging Requirements + +**Only log these milestone events:** +- User login/logout +- Message sent/received +- Data sync completed +- Important state transitions + +```dart +_logger.info( + message: 'User logged in', + extra: {'user_id': user.id}, +); +``` + +### Warning Logging Requirements + +**Only log when taking corrective action:** +- Retrying after failure +- Using fallback data +- Skipping malformed data +- Deprecation warnings + +```dart +_logger.warning( + message: 'Cache miss, loading from remote', + extra: {'key': cacheKey}, +); +``` + +### Module Naming Convention + +| Feature | Module Path | +|---------|------------| +| auth | `features.auth` | +| calendar | `features.calendar` | +| chat | `features.chat` | +| contacts | `features.contacts` | +| home | `features.home` | +| messages | `features.messages` | +| settings | `features.settings` | +| todo | `features.todo` | + +### Prohibited Practices + +- **Never** log sensitive data: passwords, tokens, PII, message content +- **Never** log at debug level in production (release mode) +- **Never** skip error logging even if you "handle" the error +- **Never** log for every iteration in loops - only on failures diff --git a/apps/README.md b/apps/README.md new file mode 100644 index 0000000..a5412d8 --- /dev/null +++ b/apps/README.md @@ -0,0 +1,16 @@ +# meeyao_qianwen + +A new Flutter project. + +## Getting Started + +This project is a starting point for a Flutter application. + +A few resources to get you started if this is your first Flutter project: + +- [Lab: Write your first Flutter app](https://docs.flutter.dev/get-started/codelab) +- [Cookbook: Useful Flutter samples](https://docs.flutter.dev/cookbook) + +For help getting started with Flutter development, view the +[online documentation](https://docs.flutter.dev/), which offers tutorials, +samples, guidance on mobile development, and a full API reference. diff --git a/apps/analysis_options.yaml b/apps/analysis_options.yaml new file mode 100644 index 0000000..0d29021 --- /dev/null +++ b/apps/analysis_options.yaml @@ -0,0 +1,28 @@ +# This file configures the analyzer, which statically analyzes Dart code to +# check for errors, warnings, and lints. +# +# The issues identified by the analyzer are surfaced in the UI of Dart-enabled +# IDEs (https://dart.dev/tools#ides-and-editors). The analyzer can also be +# invoked from the command line by running `flutter analyze`. + +# The following line activates a set of recommended lints for Flutter apps, +# packages, and plugins designed to encourage good coding practices. +include: package:flutter_lints/flutter.yaml + +linter: + # The lint rules applied to this project can be customized in the + # section below to disable rules from the `package:flutter_lints/flutter.yaml` + # included above or to enable additional rules. A list of all available lints + # and their documentation is published at https://dart.dev/lints. + # + # Instead of disabling a lint rule for the entire project in the + # section below, it can also be suppressed for a single line of code + # or a specific dart file by using the `// ignore: name_of_lint` and + # `// ignore_for_file: name_of_lint` syntax on the line or in the file + # producing the lint. + rules: + # avoid_print: false # Uncomment to disable the `avoid_print` rule + # prefer_single_quotes: true # Uncomment to enable the `prefer_single_quotes` rule + +# Additional information about this file can be found at +# https://dart.dev/guides/language/analysis-options diff --git a/apps/android/.gitignore b/apps/android/.gitignore new file mode 100644 index 0000000..be3943c --- /dev/null +++ b/apps/android/.gitignore @@ -0,0 +1,14 @@ +gradle-wrapper.jar +/.gradle +/captures/ +/gradlew +/gradlew.bat +/local.properties +GeneratedPluginRegistrant.java +.cxx/ + +# Remember to never publicly share your keystore. +# See https://flutter.dev/to/reference-keystore +key.properties +**/*.keystore +**/*.jks diff --git a/apps/android/app/build.gradle.kts b/apps/android/app/build.gradle.kts new file mode 100644 index 0000000..0457f73 --- /dev/null +++ b/apps/android/app/build.gradle.kts @@ -0,0 +1,44 @@ +plugins { + id("com.android.application") + id("kotlin-android") + // The Flutter Gradle Plugin must be applied after the Android and Kotlin Gradle plugins. + id("dev.flutter.flutter-gradle-plugin") +} + +android { + namespace = "com.meeyao.meeyao_qianwen" + compileSdk = flutter.compileSdkVersion + ndkVersion = flutter.ndkVersion + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + } + + kotlinOptions { + jvmTarget = JavaVersion.VERSION_17.toString() + } + + defaultConfig { + // TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html). + applicationId = "com.meeyao.meeyao_qianwen" + // You can update the following values to match your application needs. + // For more information, see: https://flutter.dev/to/review-gradle-config. + minSdk = flutter.minSdkVersion + targetSdk = flutter.targetSdkVersion + versionCode = flutter.versionCode + versionName = flutter.versionName + } + + buildTypes { + release { + // TODO: Add your own signing config for the release build. + // Signing with the debug keys for now, so `flutter run --release` works. + signingConfig = signingConfigs.getByName("debug") + } + } +} + +flutter { + source = "../.." +} diff --git a/apps/android/app/src/debug/AndroidManifest.xml b/apps/android/app/src/debug/AndroidManifest.xml new file mode 100644 index 0000000..399f698 --- /dev/null +++ b/apps/android/app/src/debug/AndroidManifest.xml @@ -0,0 +1,7 @@ + + + + diff --git a/apps/android/app/src/main/AndroidManifest.xml b/apps/android/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000..8683223 --- /dev/null +++ b/apps/android/app/src/main/AndroidManifest.xml @@ -0,0 +1,45 @@ + + + + + + + + + + + + + + + + + + + + + diff --git a/apps/android/app/src/main/kotlin/com/meeyao/meeyao_qianwen/MainActivity.kt b/apps/android/app/src/main/kotlin/com/meeyao/meeyao_qianwen/MainActivity.kt new file mode 100644 index 0000000..48bf774 --- /dev/null +++ b/apps/android/app/src/main/kotlin/com/meeyao/meeyao_qianwen/MainActivity.kt @@ -0,0 +1,5 @@ +package com.meeyao.meeyao_qianwen + +import io.flutter.embedding.android.FlutterActivity + +class MainActivity : FlutterActivity() diff --git a/apps/android/app/src/main/res/drawable-v21/launch_background.xml b/apps/android/app/src/main/res/drawable-v21/launch_background.xml new file mode 100644 index 0000000..f74085f --- /dev/null +++ b/apps/android/app/src/main/res/drawable-v21/launch_background.xml @@ -0,0 +1,12 @@ + + + + + + + + diff --git a/apps/android/app/src/main/res/drawable/launch_background.xml b/apps/android/app/src/main/res/drawable/launch_background.xml new file mode 100644 index 0000000..304732f --- /dev/null +++ b/apps/android/app/src/main/res/drawable/launch_background.xml @@ -0,0 +1,12 @@ + + + + + + + + diff --git a/apps/android/app/src/main/res/mipmap-hdpi/ic_launcher.png b/apps/android/app/src/main/res/mipmap-hdpi/ic_launcher.png new file mode 100644 index 0000000..db77bb4 Binary files /dev/null and b/apps/android/app/src/main/res/mipmap-hdpi/ic_launcher.png differ diff --git a/apps/android/app/src/main/res/mipmap-mdpi/ic_launcher.png b/apps/android/app/src/main/res/mipmap-mdpi/ic_launcher.png new file mode 100644 index 0000000..17987b7 Binary files /dev/null and b/apps/android/app/src/main/res/mipmap-mdpi/ic_launcher.png differ diff --git a/apps/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png b/apps/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png new file mode 100644 index 0000000..09d4391 Binary files /dev/null and b/apps/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png differ diff --git a/apps/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png b/apps/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png new file mode 100644 index 0000000..d5f1c8d Binary files /dev/null and b/apps/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png differ diff --git a/apps/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png b/apps/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png new file mode 100644 index 0000000..4d6372e Binary files /dev/null and b/apps/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png differ diff --git a/apps/android/app/src/main/res/values-night/styles.xml b/apps/android/app/src/main/res/values-night/styles.xml new file mode 100644 index 0000000..06952be --- /dev/null +++ b/apps/android/app/src/main/res/values-night/styles.xml @@ -0,0 +1,18 @@ + + + + + + + diff --git a/apps/android/app/src/main/res/values/styles.xml b/apps/android/app/src/main/res/values/styles.xml new file mode 100644 index 0000000..cb1ef88 --- /dev/null +++ b/apps/android/app/src/main/res/values/styles.xml @@ -0,0 +1,18 @@ + + + + + + + diff --git a/apps/android/app/src/profile/AndroidManifest.xml b/apps/android/app/src/profile/AndroidManifest.xml new file mode 100644 index 0000000..399f698 --- /dev/null +++ b/apps/android/app/src/profile/AndroidManifest.xml @@ -0,0 +1,7 @@ + + + + diff --git a/apps/android/build.gradle.kts b/apps/android/build.gradle.kts new file mode 100644 index 0000000..dbee657 --- /dev/null +++ b/apps/android/build.gradle.kts @@ -0,0 +1,24 @@ +allprojects { + repositories { + google() + mavenCentral() + } +} + +val newBuildDir: Directory = + rootProject.layout.buildDirectory + .dir("../../build") + .get() +rootProject.layout.buildDirectory.value(newBuildDir) + +subprojects { + val newSubprojectBuildDir: Directory = newBuildDir.dir(project.name) + project.layout.buildDirectory.value(newSubprojectBuildDir) +} +subprojects { + project.evaluationDependsOn(":app") +} + +tasks.register("clean") { + delete(rootProject.layout.buildDirectory) +} diff --git a/apps/android/gradle.properties b/apps/android/gradle.properties new file mode 100644 index 0000000..fbee1d8 --- /dev/null +++ b/apps/android/gradle.properties @@ -0,0 +1,2 @@ +org.gradle.jvmargs=-Xmx8G -XX:MaxMetaspaceSize=4G -XX:ReservedCodeCacheSize=512m -XX:+HeapDumpOnOutOfMemoryError +android.useAndroidX=true diff --git a/apps/android/gradle/wrapper/gradle-wrapper.properties b/apps/android/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..e4ef43f --- /dev/null +++ b/apps/android/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,5 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-8.14-all.zip diff --git a/apps/android/settings.gradle.kts b/apps/android/settings.gradle.kts new file mode 100644 index 0000000..ca7fe06 --- /dev/null +++ b/apps/android/settings.gradle.kts @@ -0,0 +1,26 @@ +pluginManagement { + val flutterSdkPath = + run { + val properties = java.util.Properties() + file("local.properties").inputStream().use { properties.load(it) } + val flutterSdkPath = properties.getProperty("flutter.sdk") + require(flutterSdkPath != null) { "flutter.sdk not set in local.properties" } + flutterSdkPath + } + + includeBuild("$flutterSdkPath/packages/flutter_tools/gradle") + + repositories { + google() + mavenCentral() + gradlePluginPortal() + } +} + +plugins { + id("dev.flutter.flutter-plugin-loader") version "1.0.0" + id("com.android.application") version "8.11.1" apply false + id("org.jetbrains.kotlin.android") version "2.2.20" apply false +} + +include(":app") diff --git a/apps/assets/images/logo.png b/apps/assets/images/logo.png new file mode 100644 index 0000000..355b95d Binary files /dev/null and b/apps/assets/images/logo.png differ diff --git a/apps/ios/.gitignore b/apps/ios/.gitignore new file mode 100644 index 0000000..7a7f987 --- /dev/null +++ b/apps/ios/.gitignore @@ -0,0 +1,34 @@ +**/dgph +*.mode1v3 +*.mode2v3 +*.moved-aside +*.pbxuser +*.perspectivev3 +**/*sync/ +.sconsign.dblite +.tags* +**/.vagrant/ +**/DerivedData/ +Icon? +**/Pods/ +**/.symlinks/ +profile +xcuserdata +**/.generated/ +Flutter/App.framework +Flutter/Flutter.framework +Flutter/Flutter.podspec +Flutter/Generated.xcconfig +Flutter/ephemeral/ +Flutter/app.flx +Flutter/app.zip +Flutter/flutter_assets/ +Flutter/flutter_export_environment.sh +ServiceDefinitions.json +Runner/GeneratedPluginRegistrant.* + +# Exceptions to above rules. +!default.mode1v3 +!default.mode2v3 +!default.pbxuser +!default.perspectivev3 diff --git a/apps/ios/Flutter/AppFrameworkInfo.plist b/apps/ios/Flutter/AppFrameworkInfo.plist new file mode 100644 index 0000000..1dc6cf7 --- /dev/null +++ b/apps/ios/Flutter/AppFrameworkInfo.plist @@ -0,0 +1,26 @@ + + + + + CFBundleDevelopmentRegion + en + CFBundleExecutable + App + CFBundleIdentifier + io.flutter.flutter.app + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + App + CFBundlePackageType + FMWK + CFBundleShortVersionString + 1.0 + CFBundleSignature + ???? + CFBundleVersion + 1.0 + MinimumOSVersion + 13.0 + + diff --git a/apps/ios/Flutter/Debug.xcconfig b/apps/ios/Flutter/Debug.xcconfig new file mode 100644 index 0000000..592ceee --- /dev/null +++ b/apps/ios/Flutter/Debug.xcconfig @@ -0,0 +1 @@ +#include "Generated.xcconfig" diff --git a/apps/ios/Flutter/Release.xcconfig b/apps/ios/Flutter/Release.xcconfig new file mode 100644 index 0000000..592ceee --- /dev/null +++ b/apps/ios/Flutter/Release.xcconfig @@ -0,0 +1 @@ +#include "Generated.xcconfig" diff --git a/apps/ios/Runner/AppDelegate.swift b/apps/ios/Runner/AppDelegate.swift new file mode 100644 index 0000000..6266644 --- /dev/null +++ b/apps/ios/Runner/AppDelegate.swift @@ -0,0 +1,13 @@ +import Flutter +import UIKit + +@main +@objc class AppDelegate: FlutterAppDelegate { + override func application( + _ application: UIApplication, + didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? + ) -> Bool { + GeneratedPluginRegistrant.register(with: self) + return super.application(application, didFinishLaunchingWithOptions: launchOptions) + } +} diff --git a/apps/ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json b/apps/ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 0000000..d36b1fa --- /dev/null +++ b/apps/ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1,122 @@ +{ + "images" : [ + { + "size" : "20x20", + "idiom" : "iphone", + "filename" : "Icon-App-20x20@2x.png", + "scale" : "2x" + }, + { + "size" : "20x20", + "idiom" : "iphone", + "filename" : "Icon-App-20x20@3x.png", + "scale" : "3x" + }, + { + "size" : "29x29", + "idiom" : "iphone", + "filename" : "Icon-App-29x29@1x.png", + "scale" : "1x" + }, + { + "size" : "29x29", + "idiom" : "iphone", + "filename" : "Icon-App-29x29@2x.png", + "scale" : "2x" + }, + { + "size" : "29x29", + "idiom" : "iphone", + "filename" : "Icon-App-29x29@3x.png", + "scale" : "3x" + }, + { + "size" : "40x40", + "idiom" : "iphone", + "filename" : "Icon-App-40x40@2x.png", + "scale" : "2x" + }, + { + "size" : "40x40", + "idiom" : "iphone", + "filename" : "Icon-App-40x40@3x.png", + "scale" : "3x" + }, + { + "size" : "60x60", + "idiom" : "iphone", + "filename" : "Icon-App-60x60@2x.png", + "scale" : "2x" + }, + { + "size" : "60x60", + "idiom" : "iphone", + "filename" : "Icon-App-60x60@3x.png", + "scale" : "3x" + }, + { + "size" : "20x20", + "idiom" : "ipad", + "filename" : "Icon-App-20x20@1x.png", + "scale" : "1x" + }, + { + "size" : "20x20", + "idiom" : "ipad", + "filename" : "Icon-App-20x20@2x.png", + "scale" : "2x" + }, + { + "size" : "29x29", + "idiom" : "ipad", + "filename" : "Icon-App-29x29@1x.png", + "scale" : "1x" + }, + { + "size" : "29x29", + "idiom" : "ipad", + "filename" : "Icon-App-29x29@2x.png", + "scale" : "2x" + }, + { + "size" : "40x40", + "idiom" : "ipad", + "filename" : "Icon-App-40x40@1x.png", + "scale" : "1x" + }, + { + "size" : "40x40", + "idiom" : "ipad", + "filename" : "Icon-App-40x40@2x.png", + "scale" : "2x" + }, + { + "size" : "76x76", + "idiom" : "ipad", + "filename" : "Icon-App-76x76@1x.png", + "scale" : "1x" + }, + { + "size" : "76x76", + "idiom" : "ipad", + "filename" : "Icon-App-76x76@2x.png", + "scale" : "2x" + }, + { + "size" : "83.5x83.5", + "idiom" : "ipad", + "filename" : "Icon-App-83.5x83.5@2x.png", + "scale" : "2x" + }, + { + "size" : "1024x1024", + "idiom" : "ios-marketing", + "filename" : "Icon-App-1024x1024@1x.png", + "scale" : "1x" + } + ], + "info" : { + "version" : 1, + "author" : "xcode" + } +} diff --git a/apps/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png b/apps/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png new file mode 100644 index 0000000..dc9ada4 Binary files /dev/null and b/apps/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png differ diff --git a/apps/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png b/apps/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png new file mode 100644 index 0000000..7353c41 Binary files /dev/null and b/apps/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png differ diff --git a/apps/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png b/apps/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png new file mode 100644 index 0000000..797d452 Binary files /dev/null and b/apps/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png differ diff --git a/apps/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png b/apps/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png new file mode 100644 index 0000000..6ed2d93 Binary files /dev/null and b/apps/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png differ diff --git a/apps/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png b/apps/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png new file mode 100644 index 0000000..4cd7b00 Binary files /dev/null and b/apps/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png differ diff --git a/apps/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png b/apps/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png new file mode 100644 index 0000000..fe73094 Binary files /dev/null and b/apps/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png differ diff --git a/apps/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png b/apps/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png new file mode 100644 index 0000000..321773c Binary files /dev/null and b/apps/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png differ diff --git a/apps/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png b/apps/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png new file mode 100644 index 0000000..797d452 Binary files /dev/null and b/apps/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png differ diff --git a/apps/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png b/apps/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png new file mode 100644 index 0000000..502f463 Binary files /dev/null and b/apps/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png differ diff --git a/apps/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png b/apps/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png new file mode 100644 index 0000000..0ec3034 Binary files /dev/null and b/apps/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png differ diff --git a/apps/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png b/apps/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png new file mode 100644 index 0000000..0ec3034 Binary files /dev/null and b/apps/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png differ diff --git a/apps/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png b/apps/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png new file mode 100644 index 0000000..e9f5fea Binary files /dev/null and b/apps/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png differ diff --git a/apps/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png b/apps/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png new file mode 100644 index 0000000..84ac32a Binary files /dev/null and b/apps/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png differ diff --git a/apps/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png b/apps/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png new file mode 100644 index 0000000..8953cba Binary files /dev/null and b/apps/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png differ diff --git a/apps/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png b/apps/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png new file mode 100644 index 0000000..0467bf1 Binary files /dev/null and b/apps/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png differ diff --git a/apps/ios/Runner/Assets.xcassets/LaunchImage.imageset/Contents.json b/apps/ios/Runner/Assets.xcassets/LaunchImage.imageset/Contents.json new file mode 100644 index 0000000..0bedcf2 --- /dev/null +++ b/apps/ios/Runner/Assets.xcassets/LaunchImage.imageset/Contents.json @@ -0,0 +1,23 @@ +{ + "images" : [ + { + "idiom" : "universal", + "filename" : "LaunchImage.png", + "scale" : "1x" + }, + { + "idiom" : "universal", + "filename" : "LaunchImage@2x.png", + "scale" : "2x" + }, + { + "idiom" : "universal", + "filename" : "LaunchImage@3x.png", + "scale" : "3x" + } + ], + "info" : { + "version" : 1, + "author" : "xcode" + } +} diff --git a/apps/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage.png b/apps/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage.png new file mode 100644 index 0000000..9da19ea Binary files /dev/null and b/apps/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage.png differ diff --git a/apps/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@2x.png b/apps/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@2x.png new file mode 100644 index 0000000..9da19ea Binary files /dev/null and b/apps/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@2x.png differ diff --git a/apps/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@3x.png b/apps/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@3x.png new file mode 100644 index 0000000..9da19ea Binary files /dev/null and b/apps/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@3x.png differ diff --git a/apps/ios/Runner/Assets.xcassets/LaunchImage.imageset/README.md b/apps/ios/Runner/Assets.xcassets/LaunchImage.imageset/README.md new file mode 100644 index 0000000..89c2725 --- /dev/null +++ b/apps/ios/Runner/Assets.xcassets/LaunchImage.imageset/README.md @@ -0,0 +1,5 @@ +# Launch Screen Assets + +You can customize the launch screen with your own desired assets by replacing the image files in this directory. + +You can also do it by opening your Flutter project's Xcode project with `open ios/Runner.xcworkspace`, selecting `Runner/Assets.xcassets` in the Project Navigator and dropping in the desired images. \ No newline at end of file diff --git a/apps/ios/Runner/Base.lproj/LaunchScreen.storyboard b/apps/ios/Runner/Base.lproj/LaunchScreen.storyboard new file mode 100644 index 0000000..f2e259c --- /dev/null +++ b/apps/ios/Runner/Base.lproj/LaunchScreen.storyboard @@ -0,0 +1,37 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/apps/ios/Runner/Base.lproj/Main.storyboard b/apps/ios/Runner/Base.lproj/Main.storyboard new file mode 100644 index 0000000..f3c2851 --- /dev/null +++ b/apps/ios/Runner/Base.lproj/Main.storyboard @@ -0,0 +1,26 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/apps/ios/Runner/Info.plist b/apps/ios/Runner/Info.plist new file mode 100644 index 0000000..defc42d --- /dev/null +++ b/apps/ios/Runner/Info.plist @@ -0,0 +1,49 @@ + + + + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleDisplayName + Meeyao Qianwen + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + meeyao_qianwen + CFBundlePackageType + APPL + CFBundleShortVersionString + $(FLUTTER_BUILD_NAME) + CFBundleSignature + ???? + CFBundleVersion + $(FLUTTER_BUILD_NUMBER) + LSRequiresIPhoneOS + + UILaunchStoryboardName + LaunchScreen + UIMainStoryboardFile + Main + UISupportedInterfaceOrientations + + UIInterfaceOrientationPortrait + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + UISupportedInterfaceOrientations~ipad + + UIInterfaceOrientationPortrait + UIInterfaceOrientationPortraitUpsideDown + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + CADisableMinimumFrameDurationOnPhone + + UIApplicationSupportsIndirectInputEvents + + + diff --git a/apps/ios/Runner/Runner-Bridging-Header.h b/apps/ios/Runner/Runner-Bridging-Header.h new file mode 100644 index 0000000..308a2a5 --- /dev/null +++ b/apps/ios/Runner/Runner-Bridging-Header.h @@ -0,0 +1 @@ +#import "GeneratedPluginRegistrant.h" diff --git a/apps/ios/RunnerTests/RunnerTests.swift b/apps/ios/RunnerTests/RunnerTests.swift new file mode 100644 index 0000000..86a7c3b --- /dev/null +++ b/apps/ios/RunnerTests/RunnerTests.swift @@ -0,0 +1,12 @@ +import Flutter +import UIKit +import XCTest + +class RunnerTests: XCTestCase { + + func testExample() { + // If you add code to the Runner application, consider adding tests here. + // See https://developer.apple.com/documentation/xctest for more information about using XCTest. + } + +} diff --git a/apps/l10n.yaml b/apps/l10n.yaml new file mode 100644 index 0000000..9fcf641 --- /dev/null +++ b/apps/l10n.yaml @@ -0,0 +1,4 @@ +arb-dir: lib/l10n +template-arb-file: app_zh.arb +output-localization-file: app_localizations.dart +output-class: AppLocalizations diff --git a/apps/lib/l10n/app_zh.arb b/apps/lib/l10n/app_zh.arb new file mode 100644 index 0000000..96c94f0 --- /dev/null +++ b/apps/lib/l10n/app_zh.arb @@ -0,0 +1,69 @@ +{ + "@@locale": "zh", + "appTitle": "觅爻签问", + "welcomeLogin": "欢迎登录", + "loginSubtitle": "请使用手机号登录", + "phoneHint": "请输入手机号码", + "codeHint": "请输入验证码", + "sendCode": "获取验证码", + "sending": "发送中...", + "retryAfter": "{seconds}秒后重试", + "@retryAfter": { + "placeholders": { + "seconds": { + "type": "int" + } + } + }, + "login": "登录", + "agreementPrefix": "我已阅读并同意", + "privacyPolicy": "隐私政策", + "termsOfService": "服务条款", + "disclaimer": "免责声明", + "icp": "粤ICP备2025428416号-1A", + "invalidPhone": "请输入正确的手机号码", + "invalidCode": "请输入6位验证码", + "agreementRequired": "请先勾选协议", + "codeSent": "验证码已发送,请注意查收", + "mockLoginSuccess": "模拟登录成功", + "helloUser": "您好,{name}", + "@helloUser": { + "placeholders": { + "name": { + "type": "String" + } + } + }, + "startJourney": "开始您的卦象之旅", + "journeySubtitle": "借助AI智能,探索未来的可能", + "startNow": "立即起卦", + "historyTitle": "历史解卦", + "more": "更多", + "noRecords": "暂无记录", + "noRecordsSubtitle": "您并没有保存任何卦象", + "homeTab": "首页", + "profileTab": "我的", + "notify": "消息通知", + "featurePending": "该功能暂未接入数据", + "welcomeDialogTitle": "欢迎使用觅爻签问", + "welcomeParagraph1": "你好,欢迎来到觅爻签问,这是一个借助于AI解读传统六爻卦象的平台,为用户了解中国传统易学文化提供一个窗口。", + "welcomeParagraph2": "六爻卦象源于《周易》深邃的哲学体系,是古人探索世界运行规律的一种独特方法。古人认为宇宙万物相互关联,在你起卦时,你的心念与时空信息会凝结成卦象的方式呈现出来。", + "welcomeParagraph3": "觅爻签问基于这样的思路,帮助你跳出局限思维,从全局和演变趋势看清矛盾、机会与风险,为判断和行动提供参考。", + "warningTitle": "特别提醒", + "warningBody": "卦象解读结果均由AI生成,仅供娱乐参考,切不可作为商业、医疗等专业领域的决策依据。理性看待卦象,自由掌握人生。", + "scrollHint": "请向下滚动阅读全部内容", + "understood": "我已了解", + "readAllFirst": "请先阅读完整内容", + "categoryCareer": "事业学业", + "categoryLove": "情感婚姻", + "categoryMoney": "财富投资", + "signBest": "上上签", + "signGood": "中上签", + "signNormal": "中下签", + "language": "语言", + "english": "英文", + "chinese": "中文", + "privacyContent": "隐私政策内容展示占位。", + "termsContent": "服务条款内容展示占位。", + "disclaimerContent": "免责声明内容展示占位。" +} diff --git a/apps/lib/main.dart b/apps/lib/main.dart new file mode 100644 index 0000000..22d17cc --- /dev/null +++ b/apps/lib/main.dart @@ -0,0 +1,8 @@ +import 'package:flutter/widgets.dart'; + +import 'app/app.dart'; + +void main() { + WidgetsFlutterBinding.ensureInitialized(); + runApp(const EryaoApp()); +} diff --git a/apps/pubspec.yaml b/apps/pubspec.yaml new file mode 100644 index 0000000..45d4cfb --- /dev/null +++ b/apps/pubspec.yaml @@ -0,0 +1,97 @@ +name: meeyao_qianwen +description: "觅爻签问 Flutter app" +# The following line prevents the package from being accidentally published to +# pub.dev using `flutter pub publish`. This is preferred for private packages. +publish_to: 'none' # Remove this line if you wish to publish to pub.dev + +# The following defines the version and build number for your application. +# A version number is three numbers separated by dots, like 1.2.43 +# followed by an optional build number separated by a +. +# Both the version and the builder number may be overridden in flutter +# build by specifying --build-name and --build-number, respectively. +# In Android, build-name is used as versionName while build-number used as versionCode. +# Read more about Android versioning at https://developer.android.com/studio/publish/versioning +# In iOS, build-name is used as CFBundleShortVersionString while build-number is used as CFBundleVersion. +# Read more about iOS versioning at +# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html +# In Windows, build-name is used as the major, minor, and patch parts +# of the product and file versions while build-number is used as the build suffix. +version: 1.0.0+1 + +environment: + sdk: ^3.10.7 + +# Dependencies specify other packages that your package needs in order to work. +# To automatically upgrade your package dependencies to the latest versions +# consider running `flutter pub upgrade --major-versions`. Alternatively, +# dependencies can be manually updated by changing the version numbers below to +# the latest version available on pub.dev. To see which dependencies have newer +# versions available, run `flutter pub outdated`. +dependencies: + flutter: + sdk: flutter + flutter_localizations: + sdk: flutter + intl: ^0.20.2 + shared_preferences: ^2.5.3 + + # The following adds the Cupertino Icons font to your application. + # Use with the CupertinoIcons class for iOS style icons. + cupertino_icons: ^1.0.8 + +dev_dependencies: + flutter_test: + sdk: flutter + + # The "flutter_lints" package below contains a set of recommended lints to + # encourage good coding practices. The lint set provided by the package is + # activated in the `analysis_options.yaml` file located at the root of your + # package. See that file for information about deactivating specific lint + # rules and activating additional ones. + flutter_lints: ^6.0.0 + +# For information on the generic Dart part of this file, see the +# following page: https://dart.dev/tools/pub/pubspec + +# The following section is specific to Flutter packages. +flutter: + generate: true + + # The following line ensures that the Material Icons font is + # included with your application, so that you can use the icons in + # the material Icons class. + uses-material-design: true + + assets: + - assets/images/logo.png + + # To add assets to your application, add an assets section, like this: + # assets: + # - images/a_dot_burr.jpeg + # - images/a_dot_ham.jpeg + + # An image asset can refer to one or more resolution-specific "variants", see + # https://flutter.dev/to/resolution-aware-images + + # For details regarding adding assets from package dependencies, see + # https://flutter.dev/to/asset-from-package + + # To add custom fonts to your application, add a fonts section here, + # in this "flutter" section. Each entry in this list should have a + # "family" key with the font family name, and a "fonts" key with a + # list giving the asset and other descriptors for the font. For + # example: + # fonts: + # - family: Schyler + # fonts: + # - asset: fonts/Schyler-Regular.ttf + # - asset: fonts/Schyler-Italic.ttf + # style: italic + # - family: Trajan Pro + # fonts: + # - asset: fonts/TrajanPro.ttf + # - asset: fonts/TrajanPro_Bold.ttf + # weight: 700 + # + # For details regarding fonts from package dependencies, + # see https://flutter.dev/to/font-from-package diff --git a/apps/test/widget_test.dart b/apps/test/widget_test.dart new file mode 100644 index 0000000..e412171 --- /dev/null +++ b/apps/test/widget_test.dart @@ -0,0 +1,30 @@ +// This is a basic Flutter widget test. +// +// To perform an interaction with a widget in your test, use the WidgetTester +// utility in the flutter_test package. For example, you can send tap and scroll +// gestures. You can also use WidgetTester to find child widgets in the widget +// tree, read text, and verify that the values of widget properties are correct. + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import 'package:meeyao_qianwen/main.dart'; + +void main() { + testWidgets('Counter increments smoke test', (WidgetTester tester) async { + // Build our app and trigger a frame. + await tester.pumpWidget(const MyApp()); + + // Verify that our counter starts at 0. + expect(find.text('0'), findsOneWidget); + expect(find.text('1'), findsNothing); + + // Tap the '+' icon and trigger a frame. + await tester.tap(find.byIcon(Icons.add)); + await tester.pump(); + + // Verify that our counter has incremented. + expect(find.text('0'), findsNothing); + expect(find.text('1'), findsOneWidget); + }); +} diff --git a/backend/AGENTS.md b/backend/AGENTS.md index dfe90ca..eb0b94d 100644 --- a/backend/AGENTS.md +++ b/backend/AGENTS.md @@ -49,6 +49,32 @@ This file governs `backend/**` only. Keep it minimal, enforceable, and non-dupli - Strong typing required at boundaries (Pydantic/dataclass); avoid weak untyped payload contracts. - Protocol/data contract changes must stay aligned with `docs/protocols/`. +## Database Rules + +- Supabase Auth is identity source; backend enforces business authorization. +- Use service-role DB access only in backend. +- Soft delete uses `deleted_at`; reads must exclude deleted records by default. +- Alembic is the only schema migration source of truth. +- Database migrations use `./infra/scripts/dev-migrate.sh`: + - `migrate` - run migrations only + - `init-data` - seed data only + - `bootstrap` - migrate + init-data + +## Agent Runtime & Tools + +- AG-UI protocol is mandatory for agent loop behavior. +- `ToolAgentOutput.result` is the canonical tool result field. +- Tool results must be machine-oriented and include IDs/outcomes needed for chaining. + +## Tool Schema Rules for Small Models (e.g., qwen3.5-flash) + +- Prefer `operations: list[OperationModel]` over parallel arrays. +- Validate tool args with strict Pydantic models (`extra="forbid"`). +- Keep payloads JSON-native (objects/lists), shallow, and deterministic. +- Make action-specific required fields explicit and fail with structured errors. +- Return per-item outcomes (`success/failed`, identifiers, partial status) for self-correction. +- Avoid broad entry-point coercion fallbacks; fix schema/prompt alignment first. +- Do not pass provider request fields with `None` values (avoid upstream 400 blocking tool calls). ## Testing diff --git a/backend/src/__init__.py b/backend/src/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/backend/src/app.py b/backend/src/app.py deleted file mode 100644 index 38b8d66..0000000 --- a/backend/src/app.py +++ /dev/null @@ -1,14 +0,0 @@ -from __future__ import annotations - -from fastapi import FastAPI - -app = FastAPI( - title="Eryao API", - description="觅爻签问后端服务", - version="0.1.0", -) - - -@app.get("/health") -async def health_check() -> dict[str, str]: - return {"status": "ok"} diff --git a/backend/src/core/config/__init__.py b/backend/src/core/config/__init__.py deleted file mode 100644 index b1019f5..0000000 --- a/backend/src/core/config/__init__.py +++ /dev/null @@ -1,3 +0,0 @@ -from .settings import Settings, config - -__all__ = ["Settings", "config"] diff --git a/backend/src/core/config/settings.py b/backend/src/core/config/settings.py index 3ca440f..edad279 100644 --- a/backend/src/core/config/settings.py +++ b/backend/src/core/config/settings.py @@ -8,6 +8,7 @@ from pydantic import ( AnyHttpUrl, BaseModel, Field, + SecretStr, computed_field, field_validator, model_validator, @@ -118,11 +119,54 @@ class RedisSettings(BaseModel): return f"redis://{self.host}:{self.port}/{self.db}" +class SupabaseSettings(BaseModel): + public_url: AnyHttpUrl + anon_key: str = "CHANGE_ME" + service_role_key: str = "CHANGE_ME" + jwt_secret: SecretStr | None = Field(default=None, exclude=True) + jwt_algorithm: Literal["HS256"] = "HS256" + jwt_issuer: str | None = None + + @model_validator(mode="after") + def compute_defaults(self) -> "SupabaseSettings": + base = str(self.public_url).rstrip("/") + if self.jwt_issuer is None: + self.jwt_issuer = f"{base}/auth/v1" + + return self + + @computed_field + @property + def url(self) -> str: + return str(self.public_url) + + +class StorageSettings(BaseModel): + provider: Literal["supabase"] = "supabase" + signed_url_ttl_seconds: int = Field(default=600, ge=60, le=3600) + retention_days: int = Field(default=30, ge=1, le=3650) + + class AttachmentSettings(BaseModel): + bucket: str = Field(default="eryao-attachments", min_length=3, max_length=63) + max_size_mb: int = Field(default=20, ge=1, le=200) + + class AvatarSettings(BaseModel): + bucket: str = Field(default="avatars", min_length=3, max_length=63) + max_size_mb: int = Field(default=2, ge=1, le=10) + + attachment: AttachmentSettings = Field(default_factory=AttachmentSettings) + avatar: AvatarSettings = Field(default_factory=AvatarSettings) + + +class LlmSettings(BaseModel): + provider_keys: dict[str, str] = Field(default_factory=dict) + + class DatabaseSettings(BaseModel): host: str = "localhost" - port: int = 3306 - name: str = "eryao" - user: str = "root" + port: int = 5432 + name: str = "postgres" + user: str = "postgres" password: str = "CHANGE_ME" @computed_field @@ -130,83 +174,11 @@ class DatabaseSettings(BaseModel): def url(self) -> str: password = quote(self.password, safe="") return ( - f"mysql+aiomysql://{self.user}:{password}" + f"postgresql+asyncpg://{self.user}:{password}" f"@{self.host}:{self.port}/{self.name}" ) -class AppVersionSettings(BaseModel): - manifest_path: str = Field( - default="deploy/static/releases/manifest.json", - description="发布清单文件路径,相对于项目根目录", - ) - release_path_prefix: str = Field( - default="releases", - description="下载 URL 中文件目录前缀", - ) - download_base_url: AnyHttpUrl | None = Field( - default=None, - description="下载链接基础域名,如 https://your-domain.com", - ) - - @field_validator("download_base_url", mode="before") - @classmethod - def empty_download_base_url_to_none(cls, value: object) -> object: - if value == "": - return None - return value - - @field_validator("manifest_path") - @classmethod - def validate_manifest_path(cls, value: str) -> str: - normalized = Path(value) - if normalized.is_absolute() or ".." in normalized.parts: - raise ValueError("manifest_path must be a safe relative path") - return value - - -class AliyunSmsSettings(BaseModel): - access_key_id: str = "CHANGE_ME" - access_key_secret: str = "CHANGE_ME" - sign_name: str = "CHANGE_ME" - template_code: str = "CHANGE_ME" - region_id: str = "cn-hangzhou" - endpoint: str = "dysmsapi.aliyuncs.com" - test_mode: bool = False - - -class AliyunContentSecuritySettings(BaseModel): - access_key_id: str = "CHANGE_ME" - access_key_secret: str = "CHANGE_ME" - endpoint: str = "green-cip.cn-shenzhen.aliyuncs.com" - - -class AlipaySettings(BaseModel): - app_id: str = "CHANGE_ME" - merchant_id: str = "CHANGE_ME" - public_key: str = "CHANGE_ME" - private_key: str = "CHANGE_ME" - sign_type: str = "RSA2" - notify_url: str = "" - timeout_express: str = "30m" - sandbox: bool = False - - -class DeepSeekSettings(BaseModel): - api_key: str = "CHANGE_ME" - - -class AuthSettings(BaseModel): - token_expiration_days: int = 7 - token_refresh_threshold_hours: int = 2 - - -class VerificationSettings(BaseModel): - code_length: int = 6 - expiration_minutes: int = 5 - test_mode: bool = False - - class SensitiveWordSettings(BaseModel): use_aliyun: bool = True fallback_to_local: bool = True @@ -217,6 +189,11 @@ class TestSettings(BaseModel): password: str = "" +class TaskiqSettings(BaseModel): + broker_url: str | None = None + result_backend_url: str | None = None + + def _resolve_env_file() -> str: current = Path(__file__).resolve() for parent in [current, *current.parents]: @@ -233,24 +210,31 @@ class Settings(BaseSettings): runtime: RuntimeSettings = RuntimeSettings() cors: CorsSettings = CorsSettings() redis: RedisSettings = RedisSettings() - database: DatabaseSettings = DatabaseSettings() - app_version: AppVersionSettings = AppVersionSettings() - aliyun_sms: AliyunSmsSettings = AliyunSmsSettings() - aliyun_content_security: AliyunContentSecuritySettings = ( - AliyunContentSecuritySettings() + supabase: SupabaseSettings = Field( + default_factory=lambda: SupabaseSettings(public_url="http://localhost:8001") ) - alipay: AlipaySettings = AlipaySettings() - deepseek: DeepSeekSettings = DeepSeekSettings() - auth: AuthSettings = AuthSettings() - verification: VerificationSettings = VerificationSettings() - sensitive_word: SensitiveWordSettings = SensitiveWordSettings() + storage: StorageSettings = StorageSettings() + llm: LlmSettings = LlmSettings() + database: DatabaseSettings = DatabaseSettings() + sensitive_word: SensitiveWordSettings = Field(default_factory=SensitiveWordSettings) test: TestSettings = Field(default_factory=TestSettings) + taskiq: TaskiqSettings = Field(default_factory=TaskiqSettings) @computed_field @property def database_url(self) -> str: return self.database.url + @computed_field + @property + def taskiq_broker_url(self) -> str: + return self.taskiq.broker_url or self.redis.url + + @computed_field + @property + def taskiq_result_backend_url(self) -> str: + return self.taskiq.result_backend_url or self.redis.url + model_config: ClassVar[SettingsConfigDict] = SettingsConfigDict( env_file=_resolve_env_file(), env_prefix="ERYAO_", diff --git a/backend/src/core/config/static/automation/memory_extraction.yaml b/backend/src/core/config/static/automation/memory_extraction.yaml deleted file mode 100644 index c4abc4a..0000000 --- a/backend/src/core/config/static/automation/memory_extraction.yaml +++ /dev/null @@ -1,34 +0,0 @@ -input_template: | - 你正在执行一次"自动化记忆回顾与整理"任务。 - - 任务目标: - 1) 回顾最近两天的聊天与上下文,识别用户长期偏好、习惯和关键事实的变化。 - 2) 对已经失效、被否定或明显过期的信息执行遗忘。 - 3) 对新增且有证据支持的信息执行写入。 - 4) 严禁编造;没有证据就不要写入。 - 5) 只更新最小必要字段,避免过度覆盖。 - - 输出要求: - - 必须使用以下固定格式输出: - <----------【周期任务输出】----------> - 【记忆回顾】<一句人性化总结,说明今天主要发生了什么> - 【新增记忆】<按"X条:要点1;要点2"描述;没有则写"0条"> - 【遗忘记忆】<按"X条:要点1;要点2"描述;没有则写"0条"> - 【未来展望】<基于本次记忆变化,给出1-2条温和、可执行的后续建议;若暂无建议则说明"可继续观察"> - - 表达风格: - - 语言自然、温和、可读,像助理在做每日回顾。 - - 结论先行,避免空话,不要输出与任务无关的闲聊内容。 -enabled_tools: - - memory.write - - memory.forget -context: - source: latest_chat - window_mode: day - window_count: 2 -schedule: - type: daily - run_at: - hour: 8 - minute: 0 - weekdays: null diff --git a/backend/src/core/config/static/route/frontend_routes.yaml b/backend/src/core/config/static/route/frontend_routes.yaml deleted file mode 100644 index 748c7b5..0000000 --- a/backend/src/core/config/static/route/frontend_routes.yaml +++ /dev/null @@ -1,158 +0,0 @@ -version: "1.0" -routes: - - route_id: auth.boot - path: /boot - description: Bootstraps auth session and redirects to login or home. - category: auth - auth_required: false - - route_id: auth.login - path: /login - description: Login entry for unauthenticated users. - category: auth - auth_required: false - - route_id: home.main - path: / - description: Main assistant home screen. - category: home - auth_required: true - - route_id: message.invite_list - path: /messages/invites - description: Lists message invitations. - category: messages - auth_required: true - - route_id: message.invite_detail - path: /messages/invites/{id} - description: Shows details for a single invitation. - category: messages - auth_required: true - path_params: - - id - - route_id: contacts.list - path: /contacts - description: Contact list and quick relationship actions. - category: contacts - auth_required: true - - route_id: contacts.add - path: /contacts/add - description: Create or edit a contact profile. - category: contacts - auth_required: true - - route_id: calendar.dayweek - path: /calendar/dayweek - description: Day and week calendar view. - category: calendar - auth_required: true - query_params: - - date - - from - - route_id: calendar.month - path: /calendar/month - description: Month calendar overview. - category: calendar - auth_required: true - query_params: - - from - - route_id: calendar.event_detail - path: /calendar/events/{id} - description: Detail page for one calendar event. - category: calendar - auth_required: true - path_params: - - id - - route_id: calendar.event_create - path: /calendar/events/new - description: Create page for one calendar event. - category: calendar - auth_required: true - query_params: - - date - - route_id: calendar.event_edit - path: /calendar/events/{id}/edit - description: Edit page for one calendar event. - category: calendar - auth_required: true - path_params: - - id - - route_id: calendar.event_share - path: /calendar/events/{id}/share - description: Share settings page for one calendar event. - category: calendar - auth_required: true - path_params: - - id - - route_id: todo.list - path: /todo - description: Todo quadrants and backlog overview. - category: todo - auth_required: true - - route_id: todo.create - path: /todo/new - description: Create page for one todo item. - category: todo - auth_required: true - - route_id: todo.detail - path: /todo/{id} - description: Detail page for one todo item. - category: todo - auth_required: true - path_params: - - id - - route_id: todo.edit - path: /todo/{id}/edit - description: Dedicated subpage for editing one todo item (not an in-page modal). - category: todo - auth_required: true - path_params: - - id - - route_id: settings.main - path: /settings - description: Settings hub page. - category: settings - auth_required: true - - route_id: settings.features - path: /settings/features - description: Automation job list page. - category: settings - auth_required: true - - route_id: settings.job_new - path: /settings/job/new - description: Create page for one automation job. - category: settings - auth_required: true - - route_id: settings.job_detail - path: /settings/job/{id} - description: Detail page for one automation job. - category: settings - auth_required: true - path_params: - - id - - route_id: settings.memory - path: /settings/memory - description: Memory preferences and controls. - category: settings - auth_required: true - - route_id: settings.memory_user - path: /settings/memory/user - description: User memory summary view. - category: settings - auth_required: true - - route_id: settings.memory_work - path: /settings/memory/work - description: Work memory summary view. - category: settings - auth_required: true - - route_id: settings.memory_user_edit - path: /settings/memory/user/edit - description: Edit user memory details. - category: settings - auth_required: true - - route_id: settings.memory_work_edit - path: /settings/memory/work/edit - description: Edit work memory details. - category: settings - auth_required: true - - route_id: settings.edit_profile - path: /edit-profile - description: Profile editing page. - category: settings - auth_required: true diff --git a/backend/src/core/db/types.py b/backend/src/core/db/types.py index 462f8ff..f2a615d 100644 --- a/backend/src/core/db/types.py +++ b/backend/src/core/db/types.py @@ -1,6 +1,6 @@ from __future__ import annotations from sqlalchemy import JSON -from sqlalchemy.dialects.mysql import JSON as MySQLJSON +from sqlalchemy.dialects.postgresql import JSONB -json_type = JSON().with_variant(MySQLJSON, "mysql") +json_jsonb = JSON().with_variant(JSONB, "postgresql") diff --git a/backend/src/core/runtime/__init__.py b/backend/src/core/runtime/__init__.py index e69de29..4d21ee8 100644 --- a/backend/src/core/runtime/__init__.py +++ b/backend/src/core/runtime/__init__.py @@ -0,0 +1,3 @@ +from __future__ import annotations + +__all__ = [] diff --git a/backend/src/core/runtime/cli.py b/backend/src/core/runtime/cli.py index 0fe7a9a..9b289aa 100644 --- a/backend/src/core/runtime/cli.py +++ b/backend/src/core/runtime/cli.py @@ -5,9 +5,6 @@ import subprocess import sys from pathlib import Path -from apscheduler.schedulers.asyncio import AsyncIOScheduler -from apscheduler.triggers.interval import IntervalTrigger -from core.automation.scheduler import run_automation_scheduler_scan from core.config.initial.init_data import initialize_data from core.config.settings import config from core.logging import get_logger @@ -16,7 +13,6 @@ logger = get_logger("core.runtime.cli") def _resolve_alembic_path() -> Path: - """Resolve alembic.ini path relative to project root.""" project_root = Path(__file__).parents[3] alembic_path = project_root / "alembic" / "alembic.ini" if not alembic_path.exists(): @@ -25,7 +21,6 @@ def _resolve_alembic_path() -> Path: def _redact_sensitive(text: str) -> str: - """Redact sensitive information from log output.""" import re SENSITIVE_KEYS = ("password", "token", "secret", "api_key") @@ -40,7 +35,6 @@ def _redact_sensitive(text: str) -> str: def run_migrations() -> bool: - """Run alembic migrations in a subprocess to avoid event loop conflicts.""" import os logger.info("Running alembic migrations") @@ -75,7 +69,6 @@ def run_migrations() -> bool: async def run_init_data() -> bool: - """Initialize bootstrap data.""" logger.info("Running init-data") try: result = await initialize_data() @@ -90,7 +83,6 @@ async def run_init_data() -> bool: async def bootstrap() -> bool: - """Run migrations followed by init-data.""" logger.info("Starting bootstrap (migrate + init-data)") if not run_migrations(): @@ -105,52 +97,11 @@ async def bootstrap() -> bool: return True -async def run_automation_scheduler_forever() -> None: - if not config.automation_scheduler.enabled: - logger.info("Automation scheduler disabled by config") - return - - interval_seconds = int(config.automation_scheduler.interval_seconds) - batch_limit = int(config.automation_scheduler.batch_limit) - logger.info( - "Starting automation scheduler", - interval_seconds=interval_seconds, - batch_limit=batch_limit, - ) - - async def scan_job() -> None: - try: - await run_automation_scheduler_scan(limit=batch_limit) - except Exception as exc: - logger.exception("Automation scheduler scan failed", error=str(exc)) - - scheduler = AsyncIOScheduler() - scheduler.add_job( - scan_job, - trigger=IntervalTrigger(seconds=interval_seconds), - id="automation_scheduler_scan", - name="Automation scheduler scan", - replace_existing=True, - max_instances=1, - coalesce=True, - ) - scheduler.start() - - stop_event = asyncio.Event() - try: - await stop_event.wait() - finally: - scheduler.shutdown(wait=False) - - def main() -> int: - """CLI entry point.""" if len(sys.argv) < 2: logger.error("No command provided") logger.info("Usage: python -m core.runtime.cli ") - logger.info( - "Available commands: migrate, init-data, bootstrap, automation-scheduler" - ) + logger.info("Available commands: migrate, init-data, bootstrap") return 1 command = sys.argv[1] @@ -161,14 +112,9 @@ def main() -> int: success = asyncio.run(run_init_data()) elif command == "bootstrap": success = asyncio.run(bootstrap()) - elif command == "automation-scheduler": - asyncio.run(run_automation_scheduler_forever()) - return 0 else: logger.error("Unknown command", command=command) - logger.info( - "Available commands: migrate, init-data, bootstrap, automation-scheduler" - ) + logger.info("Available commands: migrate, init-data, bootstrap") return 1 return 0 if success else 1 diff --git a/backend/src/core/runtime/tasks.py b/backend/src/core/runtime/tasks.py new file mode 100644 index 0000000..4d21ee8 --- /dev/null +++ b/backend/src/core/runtime/tasks.py @@ -0,0 +1,3 @@ +from __future__ import annotations + +__all__ = [] diff --git a/backend/src/core/taskiq/__init__.py b/backend/src/core/taskiq/__init__.py new file mode 100644 index 0000000..f767be6 --- /dev/null +++ b/backend/src/core/taskiq/__init__.py @@ -0,0 +1,3 @@ +from core.taskiq.app import broker, worker_agent_broker, worker_general_broker + +__all__ = ["broker", "worker_agent_broker", "worker_general_broker"] diff --git a/backend/src/core/taskiq/app.py b/backend/src/core/taskiq/app.py new file mode 100644 index 0000000..22ab470 --- /dev/null +++ b/backend/src/core/taskiq/app.py @@ -0,0 +1,30 @@ +from __future__ import annotations + +from taskiq_redis import ListQueueBroker, RedisAsyncResultBackend + +from core.config.settings import config +from core.logging import configure_logging, log_service_banner + + +configure_logging(config) +log_service_banner( + service_name=config.runtime.service_name, + environment=config.runtime.environment, +) + + +def _build_broker(queue_name: str) -> ListQueueBroker: + return ListQueueBroker( + url=config.taskiq_broker_url, + queue_name=queue_name, + ).with_result_backend( + RedisAsyncResultBackend(redis_url=config.taskiq_result_backend_url) + ) + + +worker_agent_broker = _build_broker("agent") +worker_general_broker = _build_broker("general") + +broker = worker_agent_broker + +__all__ = ["broker", "worker_agent_broker", "worker_general_broker"] diff --git a/backend/src/models/__init__.py b/backend/src/models/__init__.py index d862204..5296c3d 100644 --- a/backend/src/models/__init__.py +++ b/backend/src/models/__init__.py @@ -1,12 +1,11 @@ -from . import user, divination, payment, notification, feedback, version, log, violation +from __future__ import annotations + +from models.llm import Llm +from models.llm_factory import LlmFactory +from models.system_agents import SystemAgents __all__ = [ - "user", - "divination", - "payment", - "notification", - "feedback", - "version", - "log", - "violation", + "Llm", + "LlmFactory", + "SystemAgents", ] diff --git a/backend/src/models/divination.py b/backend/src/models/divination.py deleted file mode 100644 index 45af944..0000000 --- a/backend/src/models/divination.py +++ /dev/null @@ -1,41 +0,0 @@ -from __future__ import annotations - -from core.db.base import Base, TimestampMixin -from sqlalchemy import BigInteger, DateTime, Integer, String, Text, func -from sqlalchemy.orm import Mapped, mapped_column - - -class DivinationRecord(TimestampMixin, Base): - __tablename__ = "user_divination_records" - - id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True) - user_id: Mapped[int] = mapped_column(BigInteger, nullable=False) - trace_id: Mapped[str] = mapped_column(String(64), nullable=False) - question: Mapped[str] = mapped_column(Text, nullable=False) - question_type: Mapped[str] = mapped_column(String(50), nullable=False) - divination_data: Mapped[str] = mapped_column(Text, nullable=False) - deepseek_request: Mapped[str] = mapped_column(Text, nullable=False) - deepseek_response: Mapped[str | None] = mapped_column(Text, nullable=True) - interpretation_result: Mapped[str | None] = mapped_column(Text, nullable=True) - api_success: Mapped[bool] = mapped_column(Integer, nullable=False, default=0) - error_message: Mapped[str | None] = mapped_column(Text, nullable=True) - api_duration_ms: Mapped[int | None] = mapped_column(BigInteger, nullable=True) - phone_number: Mapped[str | None] = mapped_column(String(20), nullable=True) - - -class DivinationHistory(TimestampMixin, Base): - __tablename__ = "user_divination_history" - - id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True) - user_id: Mapped[int] = mapped_column(BigInteger, nullable=False) - phone_number: Mapped[str] = mapped_column(String(20), nullable=False) - local_record_id: Mapped[int | None] = mapped_column(BigInteger, nullable=True) - json_data: Mapped[str] = mapped_column(Text, nullable=False) - ai_result: Mapped[str] = mapped_column(Text, nullable=False) - question_type: Mapped[str] = mapped_column(String(50), nullable=False) - question: Mapped[str] = mapped_column(Text, nullable=False) - timestamp: Mapped[int] = mapped_column(BigInteger, nullable=False) - is_active: Mapped[bool] = mapped_column(Integer, nullable=False, default=1) - sync_time: Mapped[str] = mapped_column( - DateTime, nullable=False, server_default=func.now() - ) diff --git a/backend/src/models/feedback.py b/backend/src/models/feedback.py deleted file mode 100644 index 78bb864..0000000 --- a/backend/src/models/feedback.py +++ /dev/null @@ -1,17 +0,0 @@ -from __future__ import annotations - -from core.db.base import Base -from sqlalchemy import BigInteger, DateTime, String, Text, func -from sqlalchemy.orm import Mapped, mapped_column - - -class UserFeedback(Base): - __tablename__ = "user_feedback" - - id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True) - user_id: Mapped[int] = mapped_column(BigInteger, nullable=False) - phone_number: Mapped[str] = mapped_column(String(20), nullable=False) - content: Mapped[str] = mapped_column(Text, nullable=False) - created_at: Mapped[str] = mapped_column( - DateTime, nullable=False, server_default=func.now() - ) diff --git a/backend/src/models/llm.py b/backend/src/models/llm.py new file mode 100644 index 0000000..7cd2a74 --- /dev/null +++ b/backend/src/models/llm.py @@ -0,0 +1,26 @@ +from __future__ import annotations + +import uuid + +from sqlalchemy import ForeignKey, String +from sqlalchemy.dialects.postgresql import UUID +from sqlalchemy.orm import Mapped, mapped_column + +from core.db.base import Base, SoftDeleteMixin, TimestampMixin + + +class Llm(TimestampMixin, SoftDeleteMixin, Base): + __tablename__: str = "llms" + + id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), primary_key=True, default=uuid.uuid4 + ) + factory_id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), + ForeignKey("llm_factory.id", ondelete="RESTRICT"), + nullable=False, + index=True, + ) + model_code: Mapped[str] = mapped_column( + String(50), nullable=False, unique=True, index=True + ) diff --git a/backend/src/models/llm_factory.py b/backend/src/models/llm_factory.py new file mode 100644 index 0000000..c45f93d --- /dev/null +++ b/backend/src/models/llm_factory.py @@ -0,0 +1,22 @@ +from __future__ import annotations + +import uuid + +from sqlalchemy import String, Text +from sqlalchemy.dialects.postgresql import UUID +from sqlalchemy.orm import Mapped, mapped_column + +from core.db.base import Base, SoftDeleteMixin, TimestampMixin + + +class LlmFactory(TimestampMixin, SoftDeleteMixin, Base): + __tablename__: str = "llm_factory" + + id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), primary_key=True, default=uuid.uuid4 + ) + name: Mapped[str] = mapped_column( + String(50), nullable=False, unique=True, index=True + ) + request_url: Mapped[str] = mapped_column(String(255), nullable=False) + avatar: Mapped[str | None] = mapped_column(Text, nullable=True) diff --git a/backend/src/models/log.py b/backend/src/models/log.py deleted file mode 100644 index f94bf3f..0000000 --- a/backend/src/models/log.py +++ /dev/null @@ -1,39 +0,0 @@ -from __future__ import annotations - -from core.db.base import Base -from sqlalchemy import BigInteger, DateTime, Integer, String, Text, func -from sqlalchemy.orm import Mapped, mapped_column - - -class NetworkAccessLog(Base): - __tablename__ = "network_access_logs" - - id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True) - user_id: Mapped[int | None] = mapped_column(BigInteger, nullable=True) - phone_number: Mapped[str | None] = mapped_column(String(20), nullable=True) - client_ip: Mapped[str] = mapped_column(String(45), nullable=False) - client_port: Mapped[int | None] = mapped_column(Integer, nullable=True) - server_ip: Mapped[str] = mapped_column(String(45), nullable=False) - server_port: Mapped[int] = mapped_column(Integer, nullable=False) - http_method: Mapped[str] = mapped_column(String(10), nullable=False) - request_path: Mapped[str] = mapped_column(String(500), nullable=False) - request_url: Mapped[str] = mapped_column(String(1000), nullable=False) - user_agent: Mapped[str | None] = mapped_column(String(1000), nullable=True) - device_info: Mapped[str | None] = mapped_column(Text, nullable=True) - response_status: Mapped[int | None] = mapped_column(Integer, nullable=True) - processing_time_ms: Mapped[int | None] = mapped_column(BigInteger, nullable=True) - request_size: Mapped[int | None] = mapped_column(BigInteger, nullable=True) - response_size: Mapped[int | None] = mapped_column(BigInteger, nullable=True) - x_forwarded_for: Mapped[str | None] = mapped_column(String(500), nullable=True) - x_real_ip: Mapped[str | None] = mapped_column(String(45), nullable=True) - referer: Mapped[str | None] = mapped_column(String(1000), nullable=True) - operation_type: Mapped[str | None] = mapped_column(String(50), nullable=True) - operation_result: Mapped[str | None] = mapped_column(String(20), nullable=True) - error_message: Mapped[str | None] = mapped_column(Text, nullable=True) - session_id: Mapped[str | None] = mapped_column(String(100), nullable=True) - access_time: Mapped[str] = mapped_column( - DateTime, nullable=False, server_default=func.now() - ) - created_at: Mapped[str] = mapped_column( - DateTime, nullable=False, server_default=func.now() - ) diff --git a/backend/src/models/notification.py b/backend/src/models/notification.py deleted file mode 100644 index e6865dc..0000000 --- a/backend/src/models/notification.py +++ /dev/null @@ -1,14 +0,0 @@ -from __future__ import annotations - -from core.db.base import Base -from sqlalchemy import BigInteger, String, Text -from sqlalchemy.orm import Mapped, mapped_column - - -class Notification(Base): - __tablename__ = "notification" - - id: Mapped[str] = mapped_column(String(64), primary_key=True) - title: Mapped[str] = mapped_column(String(255), nullable=False) - content: Mapped[str] = mapped_column(Text, nullable=False) - timestamp: Mapped[int] = mapped_column(BigInteger, nullable=False) diff --git a/backend/src/models/payment.py b/backend/src/models/payment.py deleted file mode 100644 index eb0b76c..0000000 --- a/backend/src/models/payment.py +++ /dev/null @@ -1,40 +0,0 @@ -from __future__ import annotations - -from core.db.base import Base, TimestampMixin -from sqlalchemy import BigInteger, DateTime, Integer, String, Text, func -from sqlalchemy.orm import Mapped, mapped_column - - -class PaymentOrder(TimestampMixin, Base): - __tablename__ = "payment_order" - - id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True) - order_no: Mapped[str] = mapped_column(String(64), unique=True, nullable=False) - user_id: Mapped[int] = mapped_column(BigInteger, nullable=False) - amount: Mapped[str] = mapped_column(String(20), nullable=False) - coin_count: Mapped[int] = mapped_column(Integer, nullable=False) - subject: Mapped[str] = mapped_column(String(256), nullable=False) - body: Mapped[str | None] = mapped_column(String(512), nullable=True) - channel: Mapped[str] = mapped_column(String(16), nullable=False) - status: Mapped[str] = mapped_column(String(16), nullable=False, default="CREATED") - trade_no: Mapped[str | None] = mapped_column(String(64), nullable=True) - payment_time: Mapped[str | None] = mapped_column(DateTime, nullable=True) - - -class PaymentRecord(Base): - __tablename__ = "payment_record" - - id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True) - order_no: Mapped[str] = mapped_column(String(64), nullable=False) - trade_no: Mapped[str] = mapped_column(String(64), nullable=False) - user_id: Mapped[int] = mapped_column(BigInteger, nullable=False) - channel: Mapped[str] = mapped_column(String(16), nullable=False) - notify_type: Mapped[str] = mapped_column(String(16), nullable=False) - trade_status: Mapped[str] = mapped_column(String(32), nullable=False) - notify_data: Mapped[str] = mapped_column(Text, nullable=False) - process_status: Mapped[str] = mapped_column(String(16), nullable=False) - process_message: Mapped[str | None] = mapped_column(String(512), nullable=True) - coin_added: Mapped[bool] = mapped_column(Integer, nullable=False, default=0) - created_at: Mapped[str] = mapped_column( - DateTime, nullable=False, server_default=func.now() - ) diff --git a/backend/src/models/system_agents.py b/backend/src/models/system_agents.py new file mode 100644 index 0000000..ed89743 --- /dev/null +++ b/backend/src/models/system_agents.py @@ -0,0 +1,32 @@ +from __future__ import annotations + +import uuid + +from sqlalchemy import JSON, ForeignKey, String +from sqlalchemy.dialects.postgresql import JSONB, UUID +from sqlalchemy.orm import Mapped, mapped_column + +from core.db.base import Base, TimestampMixin + + +class SystemAgents(TimestampMixin, Base): + __tablename__: str = "system_agents" + + agent_type: Mapped[str] = mapped_column( + String(20), + primary_key=True, + ) + llm_id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), + ForeignKey("llms.id", ondelete="RESTRICT"), + nullable=False, + ) + status: Mapped[str] = mapped_column( + String(20), + nullable=False, + ) + config: Mapped[dict] = mapped_column( + JSON().with_variant(JSONB, "postgresql"), + nullable=False, + server_default="{}", + ) diff --git a/backend/src/models/user.py b/backend/src/models/user.py deleted file mode 100644 index 4cb5ff0..0000000 --- a/backend/src/models/user.py +++ /dev/null @@ -1,52 +0,0 @@ -from __future__ import annotations - -from core.db.base import Base, TimestampMixin -from sqlalchemy import BigInteger, DateTime, Integer, String, Text, func -from sqlalchemy.orm import Mapped, mapped_column - - -class User(TimestampMixin, Base): - __tablename__ = "user_profile" - - id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True) - phone_number: Mapped[str] = mapped_column(String(20), unique=True, nullable=False) - nickname: Mapped[str] = mapped_column(String(50), nullable=False, default="") - gender: Mapped[str] = mapped_column(String(10), nullable=False, default="男") - birthday: Mapped[str] = mapped_column( - String(20), nullable=False, default="2000-01-01" - ) - signature: Mapped[str] = mapped_column(String(255), nullable=False, default="") - - -class UserToken(Base): - __tablename__ = "user_tokens" - - id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True) - user_id: Mapped[int] = mapped_column(BigInteger, nullable=False) - token: Mapped[str] = mapped_column(String(255), nullable=False) - expire_time: Mapped[str] = mapped_column(DateTime, nullable=False) - created_at: Mapped[str] = mapped_column( - DateTime, nullable=False, server_default=func.now() - ) - - -class VerificationCode(Base): - __tablename__ = "verification_codes" - - id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True) - phone_number: Mapped[str] = mapped_column(String(20), nullable=False) - code: Mapped[str] = mapped_column(String(6), nullable=False) - expiration_time: Mapped[str] = mapped_column(DateTime, nullable=False) - created_at: Mapped[str] = mapped_column( - DateTime, nullable=False, server_default=func.now() - ) - used: Mapped[bool] = mapped_column(Integer, nullable=False, default=0) - - -class UserCoin(Base): - __tablename__ = "user_coin" - - id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True) - user_id: Mapped[int] = mapped_column(BigInteger, nullable=False, unique=True) - phone_number: Mapped[str] = mapped_column(String(20), nullable=False) - coin_balance: Mapped[int] = mapped_column(Integer, nullable=False, default=3) diff --git a/backend/src/models/version.py b/backend/src/models/version.py deleted file mode 100644 index 3a3101f..0000000 --- a/backend/src/models/version.py +++ /dev/null @@ -1,19 +0,0 @@ -from __future__ import annotations - -from core.db.base import Base, TimestampMixin -from sqlalchemy import BigInteger, Integer, String, Text -from sqlalchemy.orm import Mapped, mapped_column - - -class AppVersion(TimestampMixin, Base): - __tablename__ = "app_version" - - id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True) - version_name: Mapped[str] = mapped_column(String(20), unique=True, nullable=False) - version_code: Mapped[int] = mapped_column(Integer, unique=True, nullable=False) - min_supported_version: Mapped[str] = mapped_column(String(20), nullable=False) - min_supported_code: Mapped[int] = mapped_column(Integer, nullable=False) - is_force_update: Mapped[bool] = mapped_column(Integer, nullable=False, default=0) - update_message: Mapped[str | None] = mapped_column(Text, nullable=True) - download_url: Mapped[str | None] = mapped_column(String(500), nullable=True) - is_active: Mapped[bool] = mapped_column(Integer, nullable=False, default=1) diff --git a/backend/src/models/violation.py b/backend/src/models/violation.py deleted file mode 100644 index 0158e67..0000000 --- a/backend/src/models/violation.py +++ /dev/null @@ -1,30 +0,0 @@ -from __future__ import annotations - -from core.db.base import Base -from sqlalchemy import BigInteger, DateTime, Float, Integer, String, Text, func -from sqlalchemy.orm import Mapped, mapped_column - - -class SensitiveWordViolation(Base): - __tablename__ = "sensitive_word_violations" - - id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True) - user_id: Mapped[int] = mapped_column(BigInteger, nullable=False) - content_type: Mapped[str] = mapped_column(String(20), nullable=False) - original_content: Mapped[str] = mapped_column(Text, nullable=False) - violation_type: Mapped[str] = mapped_column(String(30), nullable=False) - detection_service: Mapped[str] = mapped_column( - String(20), nullable=False, default="LOCAL" - ) - risk_level: Mapped[str | None] = mapped_column(String(50), nullable=True) - confidence: Mapped[float | None] = mapped_column(Float, nullable=True) - aliyun_response: Mapped[str | None] = mapped_column(Text, nullable=True) - matched_words: Mapped[str] = mapped_column(Text, nullable=False) - client_ip: Mapped[str | None] = mapped_column(String(45), nullable=True) - user_agent: Mapped[str | None] = mapped_column(Text, nullable=True) - violation_time: Mapped[str] = mapped_column( - DateTime, nullable=False, server_default=func.now() - ) - created_at: Mapped[str] = mapped_column( - DateTime, nullable=False, server_default=func.now() - ) diff --git a/backend/src/schemas/__init__.py b/backend/src/schemas/__init__.py index e69de29..82184cb 100644 --- a/backend/src/schemas/__init__.py +++ b/backend/src/schemas/__init__.py @@ -0,0 +1 @@ +"""Backend reusable schemas package.""" diff --git a/backend/src/schemas/agent/__init__.py b/backend/src/schemas/agent/__init__.py new file mode 100644 index 0000000..183634c --- /dev/null +++ b/backend/src/schemas/agent/__init__.py @@ -0,0 +1,68 @@ +from schemas.agent.forwarded_props import ( + ClientTimeContext, + ForwardedPropsPayload, + parse_forwarded_props_client_time, + parse_forwarded_props_runtime_mode, +) +from schemas.agent.forwarded_props import RuntimeMode +from schemas.agent.runtime_models import ( + AgentOutput, + ConstraintItem, + ExecutionMode, + KeyEntity, + NormalizedTaskInput, + ResultTyping, + ResultType, + RouterAgentOutput, + RunStatus, + TaskType, + TaskTyping, + ToolAgentOutput, + ToolStatus, + WorkerAgentOutputLite, + WorkerAgentOutputRich, + resolve_worker_output_model, +) +from schemas.agent.system_agent import AgentType, SystemAgentLLMConfig +from schemas.agent.visibility import SystemVisibilityBit, VisibilityMask, bit_mask +from schemas.agent.ui_hints import ( + UiHintAction, + UiHintIntent, + UiHintSection, + UiHintStatus, + UiHintsPayload, +) + +__all__ = [ + "AgentType", + "AgentOutput", + "ConstraintItem", + "ExecutionMode", + "ForwardedPropsPayload", + "KeyEntity", + "NormalizedTaskInput", + "ResultTyping", + "ClientTimeContext", + "ResultType", + "RouterAgentOutput", + "RunStatus", + "RuntimeMode", + "TaskType", + "TaskTyping", + "SystemAgentLLMConfig", + "SystemVisibilityBit", + "ToolAgentOutput", + "ToolStatus", + "UiHintAction", + "UiHintIntent", + "UiHintSection", + "UiHintStatus", + "UiHintsPayload", + "VisibilityMask", + "WorkerAgentOutputLite", + "WorkerAgentOutputRich", + "bit_mask", + "parse_forwarded_props_client_time", + "parse_forwarded_props_runtime_mode", + "resolve_worker_output_model", +] diff --git a/backend/src/schemas/agent/forwarded_props.py b/backend/src/schemas/agent/forwarded_props.py new file mode 100644 index 0000000..9e8250b --- /dev/null +++ b/backend/src/schemas/agent/forwarded_props.py @@ -0,0 +1,93 @@ +from __future__ import annotations + +from datetime import datetime +from enum import Enum +import re +from zoneinfo import ZoneInfo, ZoneInfoNotFoundError + +from pydantic import ( + BaseModel, + ConfigDict, + Field, + StrictInt, + ValidationError, + field_validator, +) + +_RFC3339_WITH_TZ_PATTERN = re.compile( + r"^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:\d{2})$" +) + + +class ClientTimeContext(BaseModel): + model_config = ConfigDict(extra="forbid") + + device_timezone: str = Field( + ..., + description="IANA timezone from client device, e.g. America/Los_Angeles.", + ) + client_now_iso: str = Field( + ..., + description="RFC3339 datetime with timezone offset from client device.", + ) + client_epoch_ms: StrictInt = Field( + ..., + ge=0, + description="Unix epoch milliseconds from client device.", + ) + + @field_validator("device_timezone") + @classmethod + def validate_device_timezone(cls, value: str) -> str: + try: + ZoneInfo(value) + except ZoneInfoNotFoundError as exc: + raise ValueError("invalid client_time.device_timezone") from exc + return value + + @field_validator("client_now_iso") + @classmethod + def validate_client_now_iso(cls, value: str) -> str: + if not _RFC3339_WITH_TZ_PATTERN.fullmatch(value): + raise ValueError("invalid client_time.client_now_iso") + normalized = value.replace("Z", "+00:00") + try: + parsed = datetime.fromisoformat(normalized) + except ValueError as exc: + raise ValueError("invalid client_time.client_now_iso") from exc + if parsed.tzinfo is None: + raise ValueError("invalid client_time.client_now_iso") + return value + + +class RuntimeMode(str, Enum): + CHAT = "chat" + AUTOMATION = "automation" + + +class ForwardedPropsPayload(BaseModel): + model_config = ConfigDict(extra="forbid") + + runtime_mode: RuntimeMode + client_time: ClientTimeContext | None = None + + +def parse_forwarded_props(forwarded_props: object) -> ForwardedPropsPayload: + if not isinstance(forwarded_props, dict): + raise ValueError("invalid RunAgentInput.forwardedProps") + try: + return ForwardedPropsPayload.model_validate(forwarded_props) + except ValidationError as exc: + raise ValueError("invalid RunAgentInput.forwardedProps") from exc + + +def parse_forwarded_props_client_time( + forwarded_props: object, +) -> ClientTimeContext | None: + payload = parse_forwarded_props(forwarded_props) + return payload.client_time + + +def parse_forwarded_props_runtime_mode(forwarded_props: object) -> RuntimeMode: + payload = parse_forwarded_props(forwarded_props) + return payload.runtime_mode diff --git a/backend/src/schemas/agent/runtime_models.py b/backend/src/schemas/agent/runtime_models.py new file mode 100644 index 0000000..fb8c4a2 --- /dev/null +++ b/backend/src/schemas/agent/runtime_models.py @@ -0,0 +1,177 @@ +from __future__ import annotations + +from enum import Enum +from typing import Any + +from pydantic import BaseModel, ConfigDict, Field, field_validator + +from schemas.agent.ui_hints import UiHintsPayload + + +class TaskType(str, Enum): + KNOWLEDGE = "knowledge" + RECOMMENDATION = "recommendation" + PLANNING = "planning" + SCHEDULING = "scheduling" + REMINDER_MANAGEMENT = "reminder_management" + TODO_MANAGEMENT = "todo_management" + COMMUNICATION_DRAFTING = "communication_drafting" + INFORMATION_ORGANIZATION = "information_organization" + STATUS_TRACKING = "status_tracking" + TRANSACTION_ASSIST = "transaction_assist" + ACTION_EXECUTION = "action_execution" + TROUBLESHOOTING = "troubleshooting" + UNKNOWN = "unknown" + + +class ResultType(str, Enum): + DIRECT_ANSWER = "direct_answer" + OPTIONS_WITH_RECOMMENDATION = "options_with_recommendation" + ACTION_PLAN = "action_plan" + SCHEDULE_PROPOSAL = "schedule_proposal" + TODO_LIST = "todo_list" + DRAFT_MESSAGE = "draft_message" + SUMMARY = "summary" + PROGRESS_SUMMARY = "progress_summary" + DIAGNOSIS_REPORT = "diagnosis_report" + STRUCTURED_PAYLOAD = "structured_payload" + EXECUTION_REPORT = "execution_report" + CLARIFICATION_REQUEST = "clarification_request" + SAFETY_BLOCK = "safety_block" + ERROR_REPORT = "error_report" + UNKNOWN = "unknown" + + +class TaskTyping(BaseModel): + model_config = ConfigDict(extra="forbid") + + primary: TaskType + secondary: list[TaskType] = Field(default_factory=list, max_length=3) + + +class ResultTyping(BaseModel): + model_config = ConfigDict(extra="forbid") + + primary: ResultType + secondary: list[ResultType] = Field(default_factory=list, max_length=3) + + +class ExecutionMode(str, Enum): + ONESTEP = "onestep" + TOOL_ASSISTED = "tool_assisted" + MULTISTEP = "multistep" + + +class RunStatus(str, Enum): + SUCCESS = "success" + PARTIAL_SUCCESS = "partial_success" + FAILED = "failed" + + +class ToolStatus(str, Enum): + SUCCESS = "success" + FAILURE = "failure" + PARTIAL = "partial" + + +class KeyEntity(BaseModel): + model_config = ConfigDict(extra="forbid") + + name: str + type: str + value: str | None = None + + @field_validator("value", mode="before") + @classmethod + def normalize_value(cls, value: object) -> object: + if value is None: + return None + if isinstance(value, str): + return value + if isinstance(value, bool | int | float): + return str(value) + return value + + +class ConstraintItem(BaseModel): + model_config = ConfigDict(extra="forbid") + + key: str + value: str + required: bool = True + + @field_validator("value", mode="before") + @classmethod + def normalize_value(cls, value: object) -> object: + if isinstance(value, bool | int | float): + return str(value) + return value + + +class NormalizedTaskInput(BaseModel): + model_config = ConfigDict(extra="forbid") + + user_text: str + multimodal_summary: list[str] = Field(default_factory=list) + context_summary: str = Field(default="", max_length=2000) + + +class RouterAgentOutput(BaseModel): + model_config = ConfigDict(extra="forbid") + + normalized_task_input: NormalizedTaskInput + key_entities: list[KeyEntity] = Field(default_factory=list) + constraints: list[ConstraintItem] = Field(default_factory=list) + task_typing: TaskTyping + execution_mode: ExecutionMode + result_typing: ResultTyping + + +class ErrorInfo(BaseModel): + model_config = ConfigDict(extra="forbid") + + code: str + message: str + retryable: bool = False + details: dict[str, Any] | None = None + + +class ToolAgentOutput(BaseModel): + model_config = ConfigDict(extra="forbid") + + tool_name: str + tool_call_id: str + tool_call_args: dict[str, Any] | None = None + status: ToolStatus + result: str + error: ErrorInfo | None = None + + +class WorkerAgentOutputLite(BaseModel): + model_config = ConfigDict(extra="forbid") + + status: RunStatus = RunStatus.SUCCESS + answer: str + key_points: list[str] = Field(default_factory=list) + result_type: ResultType = ResultType.UNKNOWN + suggested_actions: list[str] = Field(default_factory=list) + error: ErrorInfo | None = None + + +class WorkerAgentOutputRich(WorkerAgentOutputLite): + ui_hints: UiHintsPayload | None = None + + +class AgentOutput(WorkerAgentOutputRich): + model_config = ConfigDict(extra="forbid") + + +WorkerAgentOutput = WorkerAgentOutputLite | WorkerAgentOutputRich + + +def resolve_worker_output_model( + execution_mode: ExecutionMode, +) -> type[WorkerAgentOutputLite]: + if execution_mode == ExecutionMode.ONESTEP: + return WorkerAgentOutputLite + return WorkerAgentOutputRich diff --git a/backend/src/schemas/agent/system_agent.py b/backend/src/schemas/agent/system_agent.py new file mode 100644 index 0000000..8d01970 --- /dev/null +++ b/backend/src/schemas/agent/system_agent.py @@ -0,0 +1,30 @@ +from __future__ import annotations + +from enum import Enum + +from pydantic import BaseModel, Field + + +class AgentType(str, Enum): + ROUTER = "router" + WORKER = "worker" + + +class ContextBuildStrategy(str, Enum): + DAY = "day" + NUMBER = "number" + + +class ContextMessagesConfig(BaseModel): + mode: ContextBuildStrategy = ContextBuildStrategy.NUMBER + count: int = Field(default=20, ge=1, le=200) + + +class SystemAgentLLMConfig(BaseModel): + temperature: float | None = Field(default=None, ge=0.0, le=2.0) + max_tokens: int | None = Field(default=None, ge=1) + timeout_seconds: float | None = Field(default=30.0, gt=0.0, le=300.0) + context_messages: ContextMessagesConfig = Field( + default_factory=ContextMessagesConfig + ) + enabled_tools: list[str] = Field(default_factory=list, max_length=32) diff --git a/backend/src/schemas/agent/ui_hints.py b/backend/src/schemas/agent/ui_hints.py new file mode 100644 index 0000000..54f461b --- /dev/null +++ b/backend/src/schemas/agent/ui_hints.py @@ -0,0 +1,349 @@ +""" +UiHints - 描述性 UI 提示 + +设计原则: +- 描述性而非渲染性: 告诉编译器“要展示什么”,而不是“如何渲染” +- 最小化 token: 保持字段简洁 +- 可编译: 可机械转换为 UiSchemaRenderer +- 尽量无损: hints 中的主要内容字段应尽量被保留到 renderer 中 + +Version: 2.1 +""" + +from __future__ import annotations + +from enum import Enum +import re +from typing import Any, ClassVar, Literal + +from pydantic import BaseModel, ConfigDict, Field +from pydantic import field_validator + +_NAVIGATION_PATH_PATTERN = re.compile(r"^/[A-Za-z0-9/_-]*$") +_NAVIGATION_PARAM_KEY_PATTERN = re.compile(r"^[A-Za-z][A-Za-z0-9_]{0,31}$") +_MAX_NAVIGATION_PARAMS = 8 + + +# ============================================================ +# Enums +# ============================================================ + + +class UiHintStatus(str, Enum): + INFO = "info" + SUCCESS = "success" + WARNING = "warning" + ERROR = "error" + PENDING = "pending" + + +class UiHintIntent(str, Enum): + """主要展示意图(弱提示,不应决定字段生死)""" + + MESSAGE = "message" # 普通消息/说明 + DATA = "data" # 数据/结果摘要 + LIST = "list" # 列表为主 + STATUS = "status" # 状态结果为主 + FORM = "form" # 结构化内容(当前不表示真实输入表单) + MIXED = "mixed" # 混合内容 + + +class UiHintActionStyle(str, Enum): + PRIMARY = "primary" + SECONDARY = "secondary" + GHOST = "ghost" + DANGER = "danger" + + +class UiHintTextFormat(str, Enum): + PLAIN = "plain" + MARKDOWN = "markdown" + + +class UiHintActionType(str, Enum): + NAVIGATION = "navigation" + URL = "url" + EVENT = "event" + TOOL = "tool" + COPY = "copy" + PAYLOAD = "payload" + + +class UiHintIconSource(str, Enum): + ICON = "icon" + EMOJI = "emoji" + URL = "url" + + +# ============================================================ +# Base Config +# ============================================================ + + +class UiHintBaseModel(BaseModel): + model_config: ClassVar[ConfigDict] = ConfigDict( + extra="forbid", + populate_by_name=True, + ) + + +# ============================================================ +# Action Targets +# ============================================================ + + +class UiHintActionNavigation(UiHintBaseModel): + type: Literal["navigation"] + path: str = Field(..., description="Internal route path.") + params: dict[str, Any] | None = Field(default=None, description="Route params.") + + @field_validator("path") + @classmethod + def validate_navigation_path(cls, value: str) -> str: + path = value.strip() + if not path: + raise ValueError("navigation path must not be empty") + if len(path) > 256: + raise ValueError("navigation path is too long") + if path.startswith("//") or "://" in path: + raise ValueError("navigation path must be internal") + if "?" in path or "#" in path: + raise ValueError("navigation path must not contain query or fragment") + if ":" in path: + raise ValueError("navigation path must be concrete without placeholders") + if _NAVIGATION_PATH_PATTERN.fullmatch(path) is None: + raise ValueError("navigation path contains unsupported characters") + return path + + @field_validator("params") + @classmethod + def validate_navigation_params( + cls, value: dict[str, Any] | None + ) -> dict[str, Any] | None: + if value is None: + return None + if len(value) > _MAX_NAVIGATION_PARAMS: + raise ValueError("navigation params exceed limit") + + normalized: dict[str, Any] = {} + for key, param_value in value.items(): + if _NAVIGATION_PARAM_KEY_PATTERN.fullmatch(key) is None: + raise ValueError("navigation param key is invalid") + if isinstance(param_value, (str, int, float, bool)): + normalized[key] = param_value + continue + raise ValueError("navigation params must be scalar") + return normalized + + +class UiHintActionUrl(UiHintBaseModel): + type: Literal["url"] + url: str = Field(..., description="External URL.") + target: Literal["_self", "_blank"] | None = Field(default=None) + + +class UiHintActionEvent(UiHintBaseModel): + type: Literal["event"] + event: str = Field(..., description="Frontend event name.") + payload: dict[str, Any] | None = Field(default=None) + + +class UiHintActionTool(UiHintBaseModel): + type: Literal["tool"] + tool_id: str = Field(alias="toolId", description="Tool identifier.") + params: dict[str, Any] | None = Field(default=None) + + +class UiHintActionCopy(UiHintBaseModel): + type: Literal["copy"] + content: str = Field(..., description="Content to copy.") + success_message: str | None = Field(alias="successMessage", default=None) + + +class UiHintActionPayload(UiHintBaseModel): + type: Literal["payload"] + payload: dict[str, Any] = Field(..., description="Structured payload.") + submit_to: str | None = Field(alias="submitTo", default=None) + + +UiHintActionTarget = ( + UiHintActionNavigation + | UiHintActionUrl + | UiHintActionEvent + | UiHintActionTool + | UiHintActionCopy + | UiHintActionPayload +) + + +class UiHintAction(UiHintBaseModel): + label: str = Field(..., description="Button label.") + style: UiHintActionStyle | None = Field(default=None, description="Button style.") + disabled: bool = Field(default=False, description="Disabled state.") + action: UiHintActionTarget = Field(..., description="Action to execute.") + + +# ============================================================ +# Small Descriptive Models +# ============================================================ + + +class UiHintIcon(UiHintBaseModel): + source: UiHintIconSource = Field(default=UiHintIconSource.ICON) + value: str = Field(..., description="Icon identifier / emoji / url.") + color: str | None = Field(default=None) + size: int | None = Field(default=None) + + +class UiHintKvItem(UiHintBaseModel): + key: str = Field(..., description="Key identifier.") + label: str | None = Field(default=None, description="Display label.") + value: Any = Field(default=None, description="Value.") + copyable: bool = Field(default=False, description="Allow copy.") + + +class UiHintListItem(UiHintBaseModel): + id: str | None = Field(default=None) + title: str = Field(..., description="Item title.") + subtitle: str | None = Field(default=None) + description: str | None = Field(default=None) + icon: UiHintIcon | None = Field(default=None) + status: UiHintStatus | None = Field(default=None) + actions: list[UiHintAction] = Field(default_factory=list) + + @field_validator("status", mode="before") + @classmethod + def normalize_status(cls, value: object) -> object: + if value is None: + return None + if isinstance(value, dict): + status_type = value.get("type") + if isinstance(status_type, str): + return status_type + status_value = value.get("status") + if isinstance(status_value, str): + return status_value + return value + + +class UiHintSection(UiHintBaseModel): + title: str | None = Field(default=None, description="Section title.") + description: str | None = Field(default=None, description="Section description.") + icon: UiHintIcon | None = Field(default=None, description="Section icon.") + + content: str | None = Field(default=None, description="Main text content.") + content_format: UiHintTextFormat = Field( + default=UiHintTextFormat.PLAIN, + alias="contentFormat", + description="Section content text format.", + ) + + items: list[UiHintKvItem] = Field(default_factory=list, description="KV items.") + list_items: list[UiHintListItem] = Field( + default_factory=list, + alias="listItems", + description="List items.", + ) + actions: list[UiHintAction] = Field(default_factory=list, description="Actions.") + + +# ============================================================ +# Root Payload +# ============================================================ + + +class UiHintsPayload(UiHintBaseModel): + """ + 描述性 UI 提示 + + 设计目标: + - agent 输出尽可能短 + - 不表达布局细节 + - 编译器负责转换为完整 UiSchemaRenderer + """ + + model_config: ClassVar[ConfigDict] = ConfigDict( + extra="forbid", + populate_by_name=True, + json_schema_extra={ + "examples": [ + { + "intent": "status", + "status": "success", + "title": "日程已创建", + "body": "本次创建已成功完成。", + "items": [ + {"key": "title", "label": "主题", "value": "Q1 规划会议"}, + {"key": "time", "label": "时间", "value": "2026-03-15 14:00"}, + ], + "actions": [ + { + "label": "查看详情", + "style": "primary", + "action": { + "type": "navigation", + "path": "/calendar/evt_123", + }, + }, + { + "label": "删除", + "style": "danger", + "action": { + "type": "tool", + "toolId": "calendar.delete", + "params": {"eventId": "evt_123"}, + }, + }, + ], + } + ] + }, + ) + + version: str = Field(default="2.1") + + intent: UiHintIntent = Field( + default=UiHintIntent.MESSAGE, + description="Primary display intent.", + ) + status: UiHintStatus = Field( + default=UiHintStatus.INFO, + description="Overall status.", + ) + + title: str | None = Field(default=None, description="Top-level title.") + description: str | None = Field(default=None, description="Top-level description.") + + body: str | None = Field(default=None, description="Top-level main body text.") + body_format: UiHintTextFormat = Field( + default=UiHintTextFormat.PLAIN, + alias="bodyFormat", + description="Body text format.", + ) + + items: list[UiHintKvItem] = Field( + default_factory=list, + description="Top-level key-value items.", + ) + list_items: list[UiHintListItem] = Field( + default_factory=list, + alias="listItems", + description="Top-level list items.", + ) + sections: list[UiHintSection] = Field( + default_factory=list, + description="Grouped sections.", + ) + actions: list[UiHintAction] = Field( + default_factory=list, + description="Top-level actions.", + ) + + icon: UiHintIcon | None = Field( + default=None, + description="Top-level icon.", + ) + meta: dict[str, Any] = Field( + default_factory=dict, + description="Extra meta, e.g. requestId/toolId/traceId/userId.", + ) diff --git a/backend/src/schemas/agent/ui_schema.py b/backend/src/schemas/agent/ui_schema.py new file mode 100644 index 0000000..279db4d --- /dev/null +++ b/backend/src/schemas/agent/ui_schema.py @@ -0,0 +1,628 @@ +""" +UI Schema Renderer Protocol + +目标: +- 只保留“基础组件 + 布局容器” +- 最终返回一个 UiSchemaRenderer +- 前端只需要递归渲染 root 布局树即可 + +Version: 2.0 +""" + +from __future__ import annotations + +from enum import Enum +from typing import Any, Literal, TypedDict, Union + +# ============================================================ +# Enums +# ============================================================ + + +class UiStatus(str, Enum): + INFO = "info" + SUCCESS = "success" + WARNING = "warning" + ERROR = "error" + PENDING = "pending" + + +class IconSource(str, Enum): + ICON = "icon" + EMOJI = "emoji" + URL = "url" + + +class TextFormat(str, Enum): + PLAIN = "plain" + MARKDOWN = "markdown" + + +class TextRole(str, Enum): + TITLE = "title" + SUBTITLE = "subtitle" + BODY = "body" + CAPTION = "caption" + CODE = "code" + + +class ButtonStyle(str, Enum): + PRIMARY = "primary" + SECONDARY = "secondary" + GHOST = "ghost" + DANGER = "danger" + + +class LayoutDirection(str, Enum): + VERTICAL = "vertical" + HORIZONTAL = "horizontal" + + +class LayoutAppearance(str, Enum): + PLAIN = "plain" + CARD = "card" + SECTION = "section" + + +class LayoutAlign(str, Enum): + START = "start" + CENTER = "center" + END = "end" + STRETCH = "stretch" + + +class LayoutJustify(str, Enum): + START = "start" + CENTER = "center" + END = "end" + SPACE_BETWEEN = "space-between" + + +class RendererTheme(str, Enum): + DEFAULT = "default" + LIGHT = "light" + DARK = "dark" + + +# ============================================================ +# Meta +# ============================================================ + + +class UiMeta(TypedDict, total=False): + requestId: str + toolId: str + traceId: str + userId: str + + +# ============================================================ +# Action Payloads +# ============================================================ + + +class NavigateAction(TypedDict, total=False): + type: Literal["navigation"] + path: str + params: dict[str, Any] + + +class UrlAction(TypedDict, total=False): + type: Literal["url"] + url: str + target: Literal["_self", "_blank"] + + +class EventAction(TypedDict, total=False): + type: Literal["event"] + event: str + payload: dict[str, Any] + + +class ToolAction(TypedDict, total=False): + type: Literal["tool"] + toolId: str + params: dict[str, Any] + + +class CopyAction(TypedDict, total=False): + type: Literal["copy"] + content: str + successMessage: str + + +class PayloadAction(TypedDict, total=False): + type: Literal["payload"] + payload: dict[str, Any] + submitTo: str + + +UiActionPayload = Union[ + NavigateAction, + UrlAction, + EventAction, + ToolAction, + CopyAction, + PayloadAction, +] + + +# ============================================================ +# Shared Small Types +# ============================================================ + + +class UiIconSpec(TypedDict, total=False): + source: str + value: str + color: str + size: int + + +class UiKvItem(TypedDict, total=False): + key: str + label: str + value: Any + copyable: bool + + +class UiBaseNode(TypedDict, total=False): + id: str + visible: bool + + +# ============================================================ +# Primitive Components +# ============================================================ + + +class UiTextNode(UiBaseNode, total=False): + type: Literal["text"] + content: str + format: str # TextFormat + role: str # TextRole + status: str # UiStatus + maxLines: int + + +class UiIconNode(UiBaseNode, total=False): + type: Literal["icon"] + source: str # IconSource + value: str + color: str + size: int + + +class UiBadgeNode(UiBaseNode, total=False): + type: Literal["badge"] + label: str + status: str # UiStatus + + +class UiButtonNode(UiBaseNode, total=False): + type: Literal["button"] + label: str + style: str # ButtonStyle + disabled: bool + icon: UiIconSpec + action: UiActionPayload + + +class UiKvNode(UiBaseNode, total=False): + type: Literal["kv"] + items: list[UiKvItem] + columns: int + + +class UiDividerNode(UiBaseNode, total=False): + type: Literal["divider"] + inset: int + + +# ============================================================ +# Layout Containers +# ============================================================ + + +class UiStackNode(UiBaseNode, total=False): + type: Literal["stack"] + direction: str # LayoutDirection + gap: int + appearance: str # LayoutAppearance + status: str # UiStatus + align: str # LayoutAlign + justify: str # LayoutJustify + wrap: bool + children: list["UiNode"] + + +class UiGridNode(UiBaseNode, total=False): + type: Literal["grid"] + columns: int + gap: int + appearance: str # LayoutAppearance + status: str # UiStatus + children: list["UiNode"] + + +UiNode = Union[ + UiTextNode, + UiIconNode, + UiBadgeNode, + UiButtonNode, + UiKvNode, + UiDividerNode, + UiStackNode, + UiGridNode, +] + +UiLayoutNode = Union[UiStackNode, UiGridNode] + + +# ============================================================ +# Root Renderer +# ============================================================ + + +class UiSchemaRenderer(TypedDict, total=False): + version: str + locale: str + status: str # UiStatus + theme: str # RendererTheme + meta: UiMeta + root: UiLayoutNode + + +# ============================================================ +# Root Builder +# ============================================================ + + +def build_renderer( + root: UiLayoutNode, + *, + version: str = "2.0", + locale: str = "zh-CN", + status: UiStatus = UiStatus.INFO, + theme: RendererTheme = RendererTheme.DEFAULT, + meta: UiMeta | None = None, +) -> UiSchemaRenderer: + renderer: UiSchemaRenderer = { + "version": version, + "locale": locale, + "status": status.value, + "theme": theme.value, + "root": root, + } + if meta: + renderer["meta"] = meta + return renderer + + +# ============================================================ +# Primitive Builders +# ============================================================ + + +def build_text( + content: str, + *, + node_id: str | None = None, + format: TextFormat = TextFormat.PLAIN, + role: TextRole = TextRole.BODY, + status: UiStatus | None = None, + max_lines: int | None = None, + visible: bool = True, +) -> UiTextNode: + node: UiTextNode = { + "type": "text", + "content": content, + "format": format.value, + "role": role.value, + "visible": visible, + } + if node_id: + node["id"] = node_id + if status: + node["status"] = status.value + if max_lines is not None: + node["maxLines"] = max_lines + return node + + +def build_icon( + source: IconSource, + value: str, + *, + node_id: str | None = None, + color: str | None = None, + size: int | None = None, + visible: bool = True, +) -> UiIconNode: + node: UiIconNode = { + "type": "icon", + "source": source.value, + "value": value, + "visible": visible, + } + if node_id: + node["id"] = node_id + if color: + node["color"] = color + if size is not None: + node["size"] = size + return node + + +def build_badge( + label: str, + *, + node_id: str | None = None, + status: UiStatus = UiStatus.INFO, + visible: bool = True, +) -> UiBadgeNode: + node: UiBadgeNode = { + "type": "badge", + "label": label, + "status": status.value, + "visible": visible, + } + if node_id: + node["id"] = node_id + return node + + +def build_button( + label: str, + action: UiActionPayload, + *, + node_id: str | None = None, + style: ButtonStyle = ButtonStyle.PRIMARY, + disabled: bool = False, + icon: UiIconSpec | None = None, + visible: bool = True, +) -> UiButtonNode: + node: UiButtonNode = { + "type": "button", + "label": label, + "style": style.value, + "disabled": disabled, + "action": action, + "visible": visible, + } + if node_id: + node["id"] = node_id + if icon: + node["icon"] = icon + return node + + +def build_kv( + items: list[UiKvItem], + *, + node_id: str | None = None, + columns: int = 1, + visible: bool = True, +) -> UiKvNode: + node: UiKvNode = { + "type": "kv", + "items": items, + "columns": columns, + "visible": visible, + } + if node_id: + node["id"] = node_id + return node + + +def build_divider( + *, + node_id: str | None = None, + inset: int = 0, + visible: bool = True, +) -> UiDividerNode: + node: UiDividerNode = { + "type": "divider", + "inset": inset, + "visible": visible, + } + if node_id: + node["id"] = node_id + return node + + +# ============================================================ +# Layout Builders +# ============================================================ + + +def build_stack( + children: list[UiNode], + *, + node_id: str | None = None, + direction: LayoutDirection = LayoutDirection.VERTICAL, + gap: int = 12, + appearance: LayoutAppearance = LayoutAppearance.PLAIN, + status: UiStatus | None = None, + align: LayoutAlign = LayoutAlign.START, + justify: LayoutJustify = LayoutJustify.START, + wrap: bool = False, + visible: bool = True, +) -> UiStackNode: + node: UiStackNode = { + "type": "stack", + "direction": direction.value, + "gap": gap, + "appearance": appearance.value, + "align": align.value, + "justify": justify.value, + "wrap": wrap, + "children": children, + "visible": visible, + } + if node_id: + node["id"] = node_id + if status: + node["status"] = status.value + return node + + +def build_grid( + children: list[UiNode], + *, + columns: int, + node_id: str | None = None, + gap: int = 12, + appearance: LayoutAppearance = LayoutAppearance.PLAIN, + status: UiStatus | None = None, + visible: bool = True, +) -> UiGridNode: + node: UiGridNode = { + "type": "grid", + "columns": columns, + "gap": gap, + "appearance": appearance.value, + "children": children, + "visible": visible, + } + if node_id: + node["id"] = node_id + if status: + node["status"] = status.value + return node + + +# ============================================================ +# Small Action Builders +# ============================================================ + + +def action_navigation( + path: str, params: dict[str, Any] | None = None +) -> NavigateAction: + action: NavigateAction = {"type": "navigation", "path": path} + if params: + action["params"] = params + return action + + +def action_url(url: str, target: Literal["_self", "_blank"] = "_blank") -> UrlAction: + return {"type": "url", "url": url, "target": target} + + +def action_event(event: str, payload: dict[str, Any] | None = None) -> EventAction: + action: EventAction = {"type": "event", "event": event} + if payload: + action["payload"] = payload + return action + + +def action_tool(tool_id: str, params: dict[str, Any] | None = None) -> ToolAction: + action: ToolAction = {"type": "tool", "toolId": tool_id} + if params: + action["params"] = params + return action + + +def action_copy(content: str, success_message: str | None = None) -> CopyAction: + action: CopyAction = {"type": "copy", "content": content} + if success_message: + action["successMessage"] = success_message + return action + + +def action_payload( + payload: dict[str, Any], submit_to: str | None = None +) -> PayloadAction: + action: PayloadAction = {"type": "payload", "payload": payload} + if submit_to: + action["submitTo"] = submit_to + return action + + +# ============================================================ +# Derived Helpers (协议外的便捷封装,不是基础原语) +# ============================================================ + + +def build_card( + children: list[UiNode], + *, + node_id: str | None = None, + gap: int = 12, + status: UiStatus | None = None, +) -> UiStackNode: + return build_stack( + children, + node_id=node_id, + direction=LayoutDirection.VERTICAL, + gap=gap, + appearance=LayoutAppearance.CARD, + status=status, + ) + + +def build_section( + title: str, + children: list[UiNode], + *, + node_id: str | None = None, + description: str | None = None, + status: UiStatus | None = None, + gap: int = 12, +) -> UiStackNode: + header_nodes: list[UiNode] = [build_text(title, role=TextRole.TITLE)] + if description: + header_nodes.append(build_text(description, role=TextRole.CAPTION)) + + all_children = header_nodes + children + return build_stack( + all_children, + node_id=node_id, + direction=LayoutDirection.VERTICAL, + gap=gap, + appearance=LayoutAppearance.SECTION, + status=status, + ) + + +def build_status_panel( + title: str, + message: str, + *, + status: UiStatus, + primary_button: UiButtonNode | None = None, + secondary_button: UiButtonNode | None = None, + node_id: str | None = None, +) -> UiStackNode: + status_label = f"ui.status.{status.value}" + children: list[UiNode] = [ + build_stack( + [ + build_text(title, role=TextRole.TITLE), + build_badge(label=status_label, status=status), + ], + direction=LayoutDirection.HORIZONTAL, + gap=8, + align=LayoutAlign.CENTER, + justify=LayoutJustify.SPACE_BETWEEN, + ), + build_text(message, role=TextRole.BODY, status=status), + ] + + actions: list[UiNode] = [] + if primary_button: + actions.append(primary_button) + if secondary_button: + actions.append(secondary_button) + + if actions: + children.append( + build_stack( + actions, + direction=LayoutDirection.HORIZONTAL, + gap=8, + ) + ) + + return build_card(children, node_id=node_id, status=status) diff --git a/backend/src/schemas/agent/visibility.py b/backend/src/schemas/agent/visibility.py new file mode 100644 index 0000000..7af41d3 --- /dev/null +++ b/backend/src/schemas/agent/visibility.py @@ -0,0 +1,50 @@ +from __future__ import annotations + +from enum import IntEnum + +from pydantic import BaseModel, ConfigDict, Field, field_validator + + +class SystemVisibilityBit(IntEnum): + UI_HISTORY = 0 + CONTEXT_ASSEMBLY = 1 + + +class VisibilityMask(BaseModel): + model_config = ConfigDict(extra="forbid") + + value: int = Field(..., ge=0, le=(1 << 63) - 1) + + @classmethod + def from_bits(cls, *, bits: list[int]) -> "VisibilityMask": + mask = 0 + for bit in bits: + validate_visibility_bit(bit=bit) + mask |= 1 << bit + return cls(value=mask) + + def contains(self, *, bit: int) -> bool: + validate_visibility_bit(bit=bit) + return bool(self.value & (1 << bit)) + + +class VisibilityBitRef(BaseModel): + model_config = ConfigDict(extra="forbid") + + bit: int = Field(..., ge=0, le=63) + + @field_validator("bit") + @classmethod + def _validate_bit(cls, value: int) -> int: + validate_visibility_bit(bit=value) + return value + + +def validate_visibility_bit(*, bit: int) -> None: + if bit < 0 or bit > 63: + raise ValueError("visibility bit must be in range [0, 63]") + + +def bit_mask(*, bit: int) -> int: + validate_visibility_bit(bit=bit) + return 1 << bit diff --git a/backend/src/services/__init__.py b/backend/src/services/__init__.py index e69de29..9d48db4 100644 --- a/backend/src/services/__init__.py +++ b/backend/src/services/__init__.py @@ -0,0 +1 @@ +from __future__ import annotations diff --git a/backend/src/services/base/__init__.py b/backend/src/services/base/__init__.py new file mode 100644 index 0000000..d115d21 --- /dev/null +++ b/backend/src/services/base/__init__.py @@ -0,0 +1,28 @@ +from __future__ import annotations + +from services.base.redis import RedisService, get_or_init_redis_client, redis_service +from services.base.service_interface import ( + BaseServiceProvider, + ServiceRegistry, + close_registered_services, + initialize_registered_services, + register_service, + register_service_instance, + resolve_registered_services, +) +from services.base.supabase import SupabaseService, supabase_service + +__all__ = [ + "BaseServiceProvider", + "RedisService", + "ServiceRegistry", + "SupabaseService", + "close_registered_services", + "get_or_init_redis_client", + "initialize_registered_services", + "redis_service", + "register_service", + "register_service_instance", + "resolve_registered_services", + "supabase_service", +] diff --git a/backend/src/services/base/redis.py b/backend/src/services/base/redis.py new file mode 100644 index 0000000..86e67e0 --- /dev/null +++ b/backend/src/services/base/redis.py @@ -0,0 +1,136 @@ +from __future__ import annotations + +import asyncio +import inspect +from typing import Any, Dict, Optional + +import redis.asyncio as redis + +from core.config.settings import RedisSettings, config + +from .service_interface import BaseServiceProvider, register_service_instance + + +class RedisService(BaseServiceProvider): + def __init__(self, settings: RedisSettings | None = None) -> None: + super().__init__("redis") + self._settings = settings or config.redis + self._client: Optional[redis.Redis] = None + self._loop_id: int | None = None + + def _build_client(self) -> redis.Redis: + return redis.from_url( + self._settings.url, + decode_responses=True, + socket_connect_timeout=self._settings.socket_connect_timeout, + socket_timeout=self._settings.socket_timeout, + max_connections=self._settings.max_connections, + ) + + def _require_client(self) -> redis.Redis: + client = self._client + if client is None: + raise RuntimeError("Redis client is not initialized") + return client + + async def initialize(self, **_: Any) -> bool: + try: + client = self._build_client() + ping_result = client.ping() + if inspect.isawaitable(ping_result): + await ping_result + self._client = client + self._loop_id = _current_loop_id() + self._set_initialized(True) + self.logger.info("Redis service initialized") + return True + except Exception as exc: # noqa: BLE001 + self.logger.warning("Redis service initialization failed", error=str(exc)) + self._client = None + self._loop_id = None + self._set_initialized(False) + return False + + async def close(self) -> bool: + client = self._client + if client is None: + self._loop_id = None + return True + try: + await client.aclose() + self.logger.info("Redis service closed") + return True + except Exception as exc: # noqa: BLE001 + self.logger.exception("Redis service close failed", error=str(exc)) + return False + finally: + self._client = None + self._loop_id = None + self._set_initialized(False) + + async def health_check(self) -> Dict[str, Any]: + client = self._client + if client is None: + return {"status": "unhealthy", "details": {"error": "not initialized"}} + try: + ping_result = client.ping() + ping = ( + await ping_result if inspect.isawaitable(ping_result) else ping_result + ) + info_result = client.info() + info = ( + await info_result if inspect.isawaitable(info_result) else info_result + ) + return { + "status": "healthy" if ping else "unhealthy", + "details": { + "ping": ping, + "redis_version": info.get("redis_version"), + "connected_clients": info.get("connected_clients"), + "used_memory": info.get("used_memory_human"), + "uptime_in_seconds": info.get("uptime_in_seconds"), + }, + } + except Exception as exc: # noqa: BLE001 + self.logger.warning("Redis health check failed", error=str(exc)) + return {"status": "unhealthy", "details": {"error": str(exc)}} + + def get_client(self) -> redis.Redis: + return self._require_client() + + +def _current_loop_id() -> int | None: + try: + return id(asyncio.get_running_loop()) + except RuntimeError: + return None + + +async def get_or_init_redis_client() -> redis.Redis: + current_loop_id = _current_loop_id() + bound_loop_id = redis_service._loop_id + if ( + redis_service.is_initialized + and bound_loop_id is not None + and current_loop_id is not None + and bound_loop_id != current_loop_id + ): + redis_service.logger.warning( + "Redis client bound to different event loop; reinitializing", + previous_loop_id=bound_loop_id, + current_loop_id=current_loop_id, + ) + redis_service._client = None + redis_service._loop_id = None + redis_service._set_initialized(False) + + if not redis_service.is_initialized: + initialized = await redis_service.initialize() + if not initialized: + raise RuntimeError("Redis service initialization failed") + return redis_service.get_client() + + +redis_service: RedisService = register_service_instance("redis", RedisService()) + +__all__ = ["RedisService", "get_or_init_redis_client", "redis_service"] diff --git a/backend/src/services/base/service_interface.py b/backend/src/services/base/service_interface.py new file mode 100644 index 0000000..b516e8e --- /dev/null +++ b/backend/src/services/base/service_interface.py @@ -0,0 +1,158 @@ +from __future__ import annotations + +from abc import ABC, abstractmethod +from typing import Any, Callable, Dict, Optional, TypeVar + +from core.logging import get_logger + + +class BaseServiceProvider(ABC): + def __init__(self, service_name: str) -> None: + self.service_name = service_name + self._initialized = False + self.logger = get_logger("services.base").bind(service=service_name) + + @abstractmethod + async def initialize(self, **kwargs: Any) -> bool: + raise NotImplementedError + + @abstractmethod + async def close(self) -> bool: + raise NotImplementedError + + @abstractmethod + async def health_check(self) -> Dict[str, Any]: + raise NotImplementedError + + @property + def is_initialized(self) -> bool: + return self._initialized + + def _set_initialized(self, value: bool) -> None: + self._initialized = value + + def get_service_info(self) -> Dict[str, Any]: + return { + "name": self.service_name, + "initialized": self._initialized, + "type": self.__class__.__name__, + } + + +class ServiceRegistry: + _services: Dict[str, Callable[..., BaseServiceProvider]] = {} + + @classmethod + def register( + cls, service_name: str, factory: Callable[..., BaseServiceProvider] + ) -> None: + cls._services = {**cls._services, service_name: factory} + + @classmethod + def get_service_factory( + cls, service_name: str + ) -> Optional[Callable[..., BaseServiceProvider]]: + return cls._services.get(service_name) + + @classmethod + def list_services(cls) -> list[str]: + return sorted(cls._services.keys()) + + @classmethod + def create_service( + cls, service_name: str, **kwargs: Any + ) -> Optional[BaseServiceProvider]: + return cls.get_service(service_name, **kwargs) + + @classmethod + def get_service( + cls, service_name: str, **kwargs: Any + ) -> Optional[BaseServiceProvider]: + factory = cls.get_service_factory(service_name) + if not factory: + return None + return factory(**kwargs) + + +def register_service(service_name: str) -> Callable[[type], type]: + def decorator(service_class: type) -> type: + ServiceRegistry.register(service_name, service_class) + return service_class + + return decorator + + +TService = TypeVar("TService", bound=BaseServiceProvider) + + +def register_service_instance(service_name: str, service: TService) -> TService: + ServiceRegistry.register(service_name, lambda: service) + return service + + +def resolve_registered_services(service_names: list[str]) -> list[BaseServiceProvider]: + services: list[BaseServiceProvider] = [] + for service_name in service_names: + service = ServiceRegistry.get_service(service_name) + if service is None: + raise RuntimeError(f"Service is not registered: {service_name}") + services.append(service) + return services + + +async def close_registered_services(services: list[BaseServiceProvider]) -> bool: + lifecycle_logger = get_logger("services.base.lifecycle") + all_closed = True + for service in reversed(services): + try: + closed = await service.close() + except Exception as exc: # noqa: BLE001 + lifecycle_logger.warning( + "Failed to close service", + service=service.service_name, + error=str(exc), + ) + all_closed = False + continue + if not closed: + lifecycle_logger.warning( + "Service close returned false", + service=service.service_name, + ) + all_closed = False + return all_closed + + +async def initialize_registered_services( + service_names: list[str], +) -> tuple[bool, list[BaseServiceProvider]]: + lifecycle_logger = get_logger("services.base.lifecycle") + initialized_services: list[BaseServiceProvider] = [] + try: + services = resolve_registered_services(service_names) + except RuntimeError as exc: + lifecycle_logger.error("Failed to resolve registered services", error=str(exc)) + return False, [] + + for service in services: + try: + initialized = await service.initialize() + except Exception as exc: # noqa: BLE001 + lifecycle_logger.warning( + "Service initialization raised exception", + service=service.service_name, + error=str(exc), + ) + initialized = False + + if not initialized: + lifecycle_logger.error( + "Service initialization failed, rolling back", + service=service.service_name, + ) + await close_registered_services(initialized_services) + return False, [] + + initialized_services.append(service) + + return True, initialized_services diff --git a/backend/src/services/base/supabase.py b/backend/src/services/base/supabase.py new file mode 100644 index 0000000..64a7e4f --- /dev/null +++ b/backend/src/services/base/supabase.py @@ -0,0 +1,304 @@ +from __future__ import annotations + +import asyncio +from typing import Any + +from supabase import create_client +from storage3.exceptions import StorageApiError + +from core.config.settings import SupabaseSettings, config + +from .service_interface import BaseServiceProvider, register_service_instance + + +class SupabaseService(BaseServiceProvider): + def __init__(self, settings: SupabaseSettings | None = None) -> None: + super().__init__("supabase") + self._settings = settings or config.supabase + self._client: Any = None + self._admin_client: Any = None + + async def initialize(self, **_: Any) -> bool: + try: + self._init_clients() + await self._ensure_storage_bucket() + self._set_initialized(True) + self.logger.info("Supabase service initialized") + return True + except Exception as exc: # noqa: BLE001 + self.logger.warning( + "Supabase service initialization failed", error=str(exc) + ) + self._client = None + self._admin_client = None + self._set_initialized(False) + return False + + async def close(self) -> bool: + self._client = None + self._admin_client = None + self._set_initialized(False) + self.logger.info("Supabase service closed") + return True + + async def health_check(self) -> dict[str, Any]: + client = self._client + admin_client = self._admin_client + if client is None or admin_client is None: + return {"status": "unhealthy", "details": {"error": "not initialized"}} + try: + await asyncio.to_thread(client.auth.get_session) + await asyncio.to_thread( + admin_client.auth.admin.list_users, page=1, per_page=1 + ) + return { + "status": "healthy", + "details": { + "anon_client": "ready", + "admin_client": "ready", + }, + } + except Exception as exc: # noqa: BLE001 + self.logger.warning("Supabase health check failed", error=str(exc)) + return {"status": "unhealthy", "details": {"error": str(exc)}} + + def get_client(self) -> Any: + return self._require_client() + + def get_admin_client(self) -> Any: + return self._require_admin_client() + + def _require_client(self) -> Any: + if self._client is None or self._admin_client is None: + self._init_clients() + self._set_initialized(True) + self.logger.info("Supabase service lazily initialized") + client = self._client + if client is None: + raise RuntimeError("Supabase client is not initialized") + return client + + def _require_admin_client(self) -> Any: + if self._client is None or self._admin_client is None: + self._init_clients() + self._set_initialized(True) + self.logger.info("Supabase service lazily initialized") + admin_client = self._admin_client + if admin_client is None: + raise RuntimeError("Supabase admin client is not initialized") + return admin_client + + def _init_clients(self) -> None: + self._client = create_client( + self._settings.url, + self._settings.anon_key, + ) + self._admin_client = create_client( + self._settings.url, + self._settings.service_role_key, + ) + + async def _ensure_storage_bucket(self) -> None: + storage = getattr(self._admin_client, "storage", None) + if storage is None: + self.logger.warning("Storage client unavailable, skipping bucket check") + return + + get_bucket = getattr(storage, "get_bucket", None) + if not callable(get_bucket): + self.logger.warning("Storage get_bucket unavailable, skipping bucket check") + return + + buckets = [ + (config.storage.attachment.bucket, False), + (config.storage.avatar.bucket, True), + ] + + def _check_and_create() -> None: + for bucket_name, is_public in buckets: + try: + get_bucket(bucket_name) + self.logger.debug( + "Storage bucket already exists", bucket=bucket_name + ) + except Exception: # noqa: BLE001 + create_bucket = getattr(storage, "create_bucket", None) + if not callable(create_bucket): + self.logger.warning( + "Storage create_bucket unavailable, skipping bucket creation" + ) + return + try: + create_bucket(bucket_name, options={"public": is_public}) + self.logger.info( + "Storage bucket created", + bucket=bucket_name, + public=is_public, + ) + except Exception as exc: # noqa: BLE001 + msg = str(exc).lower() + if "already exists" in msg or "duplicate" in msg: + self.logger.debug( + "Storage bucket already exists (race)", + bucket=bucket_name, + ) + continue + self.logger.warning( + "Failed to create storage bucket", + bucket=bucket_name, + error=str(exc), + ) + + await asyncio.to_thread(_check_and_create) + + def _get_storage(self) -> Any: + """Get the storage client from admin client.""" + client = self.get_admin_client() + storage = getattr(client, "storage", None) + if storage is None: + raise RuntimeError("Supabase storage client unavailable") + return storage + + def _get_bucket_client(self, bucket: str) -> Any: + """Get a bucket client for the specified bucket.""" + storage = self._get_storage() + from_bucket = getattr(storage, "from_", None) + if not callable(from_bucket): + raise RuntimeError("Supabase storage bucket accessor unavailable") + return from_bucket(bucket) + + def _validate_bucket(self, bucket: str) -> None: + """Validate that the bucket matches one of configured storage buckets.""" + allowed_buckets = { + config.storage.attachment.bucket, + config.storage.avatar.bucket, + } + if bucket not in allowed_buckets: + raise RuntimeError("Invalid storage bucket") + + def _ensure_bucket_client(self, bucket: str) -> Any: + """Validate bucket and return authenticated bucket client.""" + self._validate_bucket(bucket) + return self._get_bucket_client(bucket) + + def _is_bucket_not_found_error(self, exc: Exception) -> bool: + """Check if the exception indicates a bucket was not found.""" + if isinstance(exc, StorageApiError): + message = str(exc).lower() + return "bucket" in message and "not found" in message + message = str(exc).lower() + return "bucket" in message and "not found" in message + + async def upload_bytes( + self, + *, + bucket: str, + path: str, + content: bytes, + content_type: str, + ) -> str: + def _upload() -> object: + bucket_client = self._ensure_bucket_client(bucket) + upload = getattr(bucket_client, "upload", None) + if not callable(upload): + raise RuntimeError("Supabase storage upload is unavailable") + return upload( + path, + content, + { + "content-type": content_type, + "upsert": "true", + }, + ) + + try: + await asyncio.to_thread(_upload) + except Exception as exc: # noqa: BLE001 + if not self._is_bucket_not_found_error(exc): + raise + await self._ensure_bucket_exists(bucket=bucket) + await asyncio.to_thread(_upload) + return path + + async def _ensure_bucket_exists(self, *, bucket: str) -> None: + def _ensure() -> None: + storage = self._get_storage() + get_bucket = getattr(storage, "get_bucket", None) + if not callable(get_bucket): + raise RuntimeError("Supabase storage get_bucket is unavailable") + try: + get_bucket(bucket) + except Exception as exc: # noqa: BLE001 + msg = str(exc).lower() + if "bucket" in msg and "not found" in msg: + raise RuntimeError(f"Storage bucket '{bucket}' does not exist") + raise + + await asyncio.to_thread(_ensure) + + async def download_bytes(self, *, bucket: str, path: str) -> bytes: + def _download() -> object: + bucket_client = self._ensure_bucket_client(bucket) + download = getattr(bucket_client, "download", None) + if not callable(download): + raise RuntimeError("Supabase storage download is unavailable") + return download(path) + + raw = await asyncio.to_thread(_download) + if isinstance(raw, bytes): + return raw + if isinstance(raw, bytearray): + return bytes(raw) + if isinstance(raw, memoryview): + return raw.tobytes() + raise RuntimeError("Invalid attachment payload") + + async def create_signed_url( + self, + *, + bucket: str, + path: str, + expires_in_seconds: int, + ) -> str: + def _create_signed_url() -> object: + bucket_client = self._ensure_bucket_client(bucket) + signer = getattr(bucket_client, "create_signed_url", None) + if not callable(signer): + raise RuntimeError("Supabase storage signed url is unavailable") + return signer(path, expires_in_seconds) + + raw = await asyncio.to_thread(_create_signed_url) + if isinstance(raw, str): + return raw + if isinstance(raw, dict): + signed_url = raw.get("signedURL") or raw.get("signedUrl") or raw.get("url") + if isinstance(signed_url, str) and signed_url: + return signed_url + raise RuntimeError("Invalid signed url payload") + + def parse_signed_url(self, url: str) -> tuple[str, str]: + from urllib.parse import urlparse + + parsed = urlparse(url) + path_parts = parsed.path.strip("/").split("/") + + if ( + len(path_parts) < 4 + or path_parts[0] != "storage" + or path_parts[1] != "v1" + or path_parts[2] != "object" + or path_parts[3] != "sign" + ): + raise RuntimeError("Invalid signed URL format") + + bucket = path_parts[4] + path = "/".join(path_parts[5:]) + + return bucket, path + + +supabase_service: SupabaseService = register_service_instance( + "supabase", SupabaseService() +) + +__all__ = ["SupabaseService", "supabase_service"] diff --git a/backend/src/services/caches/__init__.py b/backend/src/services/caches/__init__.py new file mode 100644 index 0000000..c7ca694 --- /dev/null +++ b/backend/src/services/caches/__init__.py @@ -0,0 +1,4 @@ +from .factory import get_cache_store +from .interfaces import CacheStore + +__all__ = ["CacheStore", "get_cache_store"] diff --git a/backend/src/services/caches/factory.py b/backend/src/services/caches/factory.py new file mode 100644 index 0000000..0ed3c34 --- /dev/null +++ b/backend/src/services/caches/factory.py @@ -0,0 +1,13 @@ +from __future__ import annotations + +from .interfaces import CacheStore +from .redis_store import RedisCacheStore + +_cache_store: CacheStore | None = None + + +def get_cache_store() -> CacheStore: + global _cache_store + if _cache_store is None: + _cache_store = RedisCacheStore() + return _cache_store diff --git a/backend/src/services/caches/interfaces.py b/backend/src/services/caches/interfaces.py new file mode 100644 index 0000000..ff5950b --- /dev/null +++ b/backend/src/services/caches/interfaces.py @@ -0,0 +1,19 @@ +from __future__ import annotations + +from typing import Protocol + + +class CacheStore(Protocol): + async def hgetall(self, key: str, /) -> dict[str, str]: ... + + async def hset(self, key: str, /, mapping: dict[str, str]) -> int: ... + + async def hincrby(self, key: str, field: str, amount: int = 1, /) -> int: ... + + async def expire(self, key: str, ttl_seconds: int, /) -> int: ... + + async def delete(self, *keys: str) -> int: ... + + async def sadd(self, key: str, *members: str) -> int: ... + + async def smembers(self, key: str, /) -> set[str]: ... diff --git a/backend/src/services/caches/redis_store.py b/backend/src/services/caches/redis_store.py new file mode 100644 index 0000000..9a7a7cf --- /dev/null +++ b/backend/src/services/caches/redis_store.py @@ -0,0 +1,80 @@ +from __future__ import annotations + +import inspect +from typing import Any + +from services.base.redis import get_or_init_redis_client + +from .interfaces import CacheStore + + +def _to_text(value: Any) -> str | None: + if isinstance(value, str): + return value + if isinstance(value, bytes): + try: + return value.decode("utf-8") + except UnicodeDecodeError: + return None + return None + + +async def _maybe_await(value: Any) -> Any: + if inspect.isawaitable(value): + return await value + return value + + +class RedisCacheStore(CacheStore): + async def hgetall(self, key: str) -> dict[str, str]: + client = await get_or_init_redis_client() + raw = await _maybe_await(client.hgetall(key)) + if not isinstance(raw, dict): + return {} + + decoded: dict[str, str] = {} + for raw_key, raw_value in raw.items(): + key_text = _to_text(raw_key) + value_text = _to_text(raw_value) + if key_text is None or value_text is None: + continue + decoded[key_text] = value_text + return decoded + + async def hset(self, key: str, mapping: dict[str, str]) -> int: + client = await get_or_init_redis_client() + result = await _maybe_await(client.hset(key, mapping=mapping)) + return int(result) + + async def hincrby(self, key: str, field: str, amount: int = 1) -> int: + client = await get_or_init_redis_client() + result = await _maybe_await(client.hincrby(key, field, amount)) + return int(result) + + async def expire(self, key: str, ttl_seconds: int) -> int: + client = await get_or_init_redis_client() + result = await _maybe_await(client.expire(key, ttl_seconds)) + return int(result) + + async def delete(self, *keys: str) -> int: + if not keys: + return 0 + client = await get_or_init_redis_client() + result = await _maybe_await(client.delete(*keys)) + return int(result) + + async def sadd(self, key: str, *members: str) -> int: + if not members: + return 0 + client = await get_or_init_redis_client() + result = await _maybe_await(client.sadd(key, *members)) + return int(result) + + async def smembers(self, key: str) -> set[str]: + client = await get_or_init_redis_client() + raw = await _maybe_await(client.smembers(key)) + if isinstance(raw, set): + return {value for item in raw if (value := _to_text(item))} + if isinstance(raw, list | tuple): + return {value for item in raw if (value := _to_text(item))} + return set() diff --git a/backend/src/services/llm_pricing/__init__.py b/backend/src/services/llm_pricing/__init__.py new file mode 100644 index 0000000..623a075 --- /dev/null +++ b/backend/src/services/llm_pricing/__init__.py @@ -0,0 +1,5 @@ +from __future__ import annotations + +from services.llm_pricing.service import LlmPricingService + +__all__ = ["LlmPricingService"] diff --git a/backend/src/services/llm_pricing/service.py b/backend/src/services/llm_pricing/service.py new file mode 100644 index 0000000..7c7e1dc --- /dev/null +++ b/backend/src/services/llm_pricing/service.py @@ -0,0 +1,183 @@ +from __future__ import annotations + +from dataclasses import dataclass +from typing import Any + +from core.config.initial.init_data import load_llm_catalog + + +@dataclass(frozen=True) +class PricingTier: + max_prompt_tokens: int + input_cost_per_token: float + output_cost_per_token: float + cache_hit_cost_per_token: float + + +class LlmPricingService: + _pricing_by_model: dict[str, tuple[PricingTier, ...]] + + def __init__(self) -> None: + self._pricing_by_model = self._build_pricing_map() + + @staticmethod + def _build_pricing_map() -> dict[str, tuple[PricingTier, ...]]: + catalog = load_llm_catalog() + pricing_by_model: dict[str, tuple[PricingTier, ...]] = {} + for model in catalog.get("llms", []): + if not isinstance(model, dict): + continue + model_code = str(model.get("model_code", "")).strip().lower() + raw_tiers = model.get("pricing_tiers") + if not isinstance(raw_tiers, list) or not raw_tiers: + continue + + tiers = [ + PricingTier( + max_prompt_tokens=int(item.get("max_prompt_tokens", 0) or 0), + input_cost_per_token=float( + item.get("input_cost_per_token", 0.0) or 0.0 + ), + output_cost_per_token=float( + item.get("output_cost_per_token", 0.0) or 0.0 + ), + cache_hit_cost_per_token=float( + item.get("cache_hit_cost_per_token", 0.0) or 0.0 + ), + ) + for item in raw_tiers + if isinstance(item, dict) + ] + if not tiers: + continue + ordered_tiers = tuple( + sorted(tiers, key=lambda item: item.max_prompt_tokens) + ) + if model_code: + pricing_by_model[model_code] = ordered_tiers + return pricing_by_model + + def calculate_cost( + self, + *, + model: str, + prompt_tokens: int, + completion_tokens: int, + cached_prompt_tokens: int = 0, + ) -> float: + tiers = self._pricing_by_model.get(model.strip().lower()) + if tiers is None: + raise ValueError(f"unknown model pricing: {model}") + + normalized_prompt_tokens = max(int(prompt_tokens), 0) + normalized_completion_tokens = max(int(completion_tokens), 0) + normalized_cached_tokens = min( + max(int(cached_prompt_tokens), 0), normalized_prompt_tokens + ) + uncached_prompt_tokens = normalized_prompt_tokens - normalized_cached_tokens + + selected_tier = tiers[-1] + for tier in tiers: + if normalized_prompt_tokens <= tier.max_prompt_tokens: + selected_tier = tier + break + + cached_token_rate = ( + selected_tier.cache_hit_cost_per_token + if selected_tier.cache_hit_cost_per_token > 0 + else selected_tier.input_cost_per_token + ) + + return float( + uncached_prompt_tokens * selected_tier.input_cost_per_token + + normalized_cached_tokens * cached_token_rate + + normalized_completion_tokens * selected_tier.output_cost_per_token + ) + + def build_usage_metadata( + self, + *, + model: str, + usage_summary: dict[str, Any] | None, + ) -> dict[str, Any]: + summary = usage_summary or {} + input_tokens = max(int(summary.get("input_tokens", 0) or 0), 0) + output_tokens = max(int(summary.get("output_tokens", 0) or 0), 0) + total_tokens = max( + int(summary.get("total_tokens", input_tokens + output_tokens) or 0), 0 + ) + latency_ms = max(int(summary.get("latency_ms", 0) or 0), 0) + cached_prompt_tokens = max(int(summary.get("cached_prompt_tokens", 0) or 0), 0) + prompt_cache_hit_tokens = max( + int(summary.get("prompt_cache_hit_tokens", cached_prompt_tokens) or 0), 0 + ) + prompt_cache_miss_tokens = max( + int( + summary.get( + "prompt_cache_miss_tokens", + max(input_tokens - prompt_cache_hit_tokens, 0), + ) + or 0 + ), + 0, + ) + reasoning_tokens = max(int(summary.get("reasoning_tokens", 0) or 0), 0) + direct_cost_raw = summary.get("direct_cost") + direct_cost_observed = bool(int(summary.get("direct_cost_observed", 0) or 0)) + direct_cost_complete = bool(int(summary.get("direct_cost_complete", 0) or 0)) + model_call_records = max(int(summary.get("model_call_records", 0) or 0), 0) + usage_records = max(int(summary.get("usage_records", 0) or 0), 0) + usage_complete = model_call_records == 0 or model_call_records == usage_records + direct_cost = self._coerce_non_negative_float(direct_cost_raw) + + if ( + usage_complete + and direct_cost_observed + and direct_cost_complete + and direct_cost is not None + ): + cost = direct_cost + cost_source = "provider" + else: + cost = self.calculate_cost( + model=model, + prompt_tokens=input_tokens, + completion_tokens=output_tokens, + cached_prompt_tokens=cached_prompt_tokens, + ) + cost_source = ( + "incomplete_usage_fallback" + if not usage_complete + else ( + "catalog_fallback_incomplete_provider_cost" + if direct_cost_observed and not direct_cost_complete + else "catalog_fallback" + ) + ) + + return { + "model": model, + "inputTokens": input_tokens, + "outputTokens": output_tokens, + "totalTokens": total_tokens, + "cachedPromptTokens": cached_prompt_tokens, + "promptCacheHitTokens": prompt_cache_hit_tokens, + "promptCacheMissTokens": prompt_cache_miss_tokens, + "reasoningTokens": reasoning_tokens, + "cost": cost, + "costSource": cost_source, + "usageComplete": usage_complete, + "latencyMs": latency_ms, + } + + @staticmethod + def _coerce_non_negative_float(value: Any) -> float | None: + if value is None: + return None + try: + parsed = float(value) + except (TypeError, ValueError): + return None + if parsed < 0: + return None + return parsed diff --git a/backend/src/v1/__init__.py b/backend/src/v1/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/backend/src/v1/auth/__init__.py b/backend/src/v1/auth/__init__.py new file mode 100644 index 0000000..9d48db4 --- /dev/null +++ b/backend/src/v1/auth/__init__.py @@ -0,0 +1 @@ +from __future__ import annotations diff --git a/backend/src/v1/auth/automation_static_config.py b/backend/src/v1/auth/automation_static_config.py new file mode 100644 index 0000000..ce0ab2d --- /dev/null +++ b/backend/src/v1/auth/automation_static_config.py @@ -0,0 +1,35 @@ +from __future__ import annotations + +from functools import lru_cache +from pathlib import Path +import re +from typing import Any + +import yaml + +from schemas.domain.automation import AutomationJobConfig + +_CONFIG_NAME_PATTERN = re.compile(r"^[a-z0-9][a-z0-9_-]{0,63}$") + + +def _automation_yaml_path(config_name: str) -> Path: + if not _CONFIG_NAME_PATTERN.fullmatch(config_name): + raise ValueError("invalid automation config name") + return ( + Path(__file__).resolve().parents[2] + / "core" + / "config" + / "static" + / "automation" + / f"{config_name}.yaml" + ) + + +@lru_cache(maxsize=16) +def load_static_automation_job_config(*, config_name: str) -> AutomationJobConfig: + path = _automation_yaml_path(config_name) + with path.open("r", encoding="utf-8") as file: + loaded: Any = yaml.safe_load(file) or {} + if not isinstance(loaded, dict): + raise ValueError(f"invalid automation config format: {path}") + return AutomationJobConfig.model_validate(loaded) diff --git a/backend/src/v1/auth/dependencies.py b/backend/src/v1/auth/dependencies.py new file mode 100644 index 0000000..1b96624 --- /dev/null +++ b/backend/src/v1/auth/dependencies.py @@ -0,0 +1,27 @@ +from __future__ import annotations + +from typing import Annotated + +from fastapi import Depends +from sqlalchemy.ext.asyncio import AsyncSession + +from core.db import get_db +from v1.auth.gateway import SupabaseAuthGateway +from v1.auth.registration_bootstrap import ( + RegistrationAutomationBootstrapService, + RegistrationBootstrapRepository, +) +from v1.auth.service import AuthService + + +def get_auth_service( + session: Annotated[AsyncSession, Depends(get_db)], +) -> AuthService: + bootstrapper = RegistrationAutomationBootstrapService( + repository=RegistrationBootstrapRepository(session=session), + session=session, + ) + return AuthService( + gateway=SupabaseAuthGateway(), + registration_bootstrapper=bootstrapper, + ) diff --git a/backend/src/v1/auth/gateway.py b/backend/src/v1/auth/gateway.py new file mode 100644 index 0000000..ec41f5e --- /dev/null +++ b/backend/src/v1/auth/gateway.py @@ -0,0 +1,430 @@ +from __future__ import annotations + +import asyncio +import time +from typing import Any, cast + +from pydantic import ValidationError + +from supabase import AuthError + +from core.http.errors import ApiProblemError +from core.logging import get_logger +from services.base.supabase import supabase_service +from v1.auth.schemas import ( + AuthUser, + OtpSendRequest, + PhoneSessionCreateRequest, + SessionRefreshRequest, + SessionResponse, + UserByIdResponse, + UserByPhoneResponse, +) +from v1.auth.service import AuthServiceGateway + +logger = get_logger("v1.auth.gateway") + +AUTH_UNAVAILABLE_DETAIL = "Auth service temporarily unavailable" + + +def _auth_error( + *, + status_code: int, + code: str, + detail: str, +) -> ApiProblemError: + return ApiProblemError(status_code=status_code, code=code, detail=detail) + + +class SupabaseAuthGateway(AuthServiceGateway): + def __init__(self) -> None: + self._user_lookup_cache_ttl_seconds: int = 60 + self._user_lookup_cache_expires_at: float = 0.0 + self._users_by_phone: dict[str, Any] = {} + self._users_by_id: dict[str, Any] = {} + + def _get_client(self) -> Any: + return supabase_service.get_client() + + def _get_admin_client(self) -> Any: + return supabase_service.get_admin_client() + + async def send_otp(self, request: OtpSendRequest) -> None: + client = self._get_client() + payload: dict[str, Any] = { + "phone": request.phone, + "options": {"should_create_user": True}, + } + try: + sign_in_with_otp = cast(Any, client.auth.sign_in_with_otp) + await asyncio.to_thread(sign_in_with_otp, payload) + except AuthError as exc: + logger.warning("Send otp failed", error_type=type(exc).__name__) + if _is_auth_upstream_unavailable(exc): + raise _auth_error( + status_code=503, + code="AUTH_SERVICE_UNAVAILABLE", + detail=AUTH_UNAVAILABLE_DETAIL, + ) from exc + raise _auth_error( + status_code=429, + code="AUTH_TOO_MANY_REQUESTS", + detail="Too many requests", + ) from exc + + async def create_phone_session( + self, request: PhoneSessionCreateRequest + ) -> SessionResponse: + client = self._get_client() + payload: dict[str, Any] = { + "type": "sms", + "phone": request.phone, + "token": request.token, + } + try: + verify_otp = cast(Any, client.auth.verify_otp) + response = await asyncio.to_thread(verify_otp, payload) + return _map_auth_response( + response, + "Invalid verification code", + "AUTH_VERIFICATION_CODE_INVALID", + ) + except AuthError as exc: + logger.warning("Create phone session failed", error_type=type(exc).__name__) + if _is_auth_upstream_unavailable(exc): + raise _auth_error( + status_code=503, + code="AUTH_SERVICE_UNAVAILABLE", + detail=AUTH_UNAVAILABLE_DETAIL, + ) from exc + raise _auth_error( + status_code=401, + code="AUTH_VERIFICATION_CODE_INVALID", + detail="Invalid verification code", + ) from exc + + async def refresh_session(self, request: SessionRefreshRequest) -> SessionResponse: + client = self._get_client() + try: + response = await asyncio.to_thread( + client.auth.refresh_session, + request.refresh_token, + ) + return _map_auth_response( + response, + "Invalid refresh token", + "AUTH_REFRESH_TOKEN_INVALID", + ) + except AuthError as exc: + logger.warning("Refresh failed", error_type=type(exc).__name__) + if _is_auth_upstream_unavailable(exc): + raise _auth_error( + status_code=503, + code="AUTH_SERVICE_UNAVAILABLE", + detail=AUTH_UNAVAILABLE_DETAIL, + ) from exc + raise _auth_error( + status_code=401, + code="AUTH_REFRESH_TOKEN_INVALID", + detail="Invalid refresh token", + ) from exc + + async def delete_session(self, refresh_token: str | None) -> None: + if not refresh_token: + raise _auth_error( + status_code=401, + code="AUTH_REFRESH_TOKEN_MISSING", + detail="Missing refresh token", + ) + client = self._get_client() + try: + response = await asyncio.to_thread( + client.auth.refresh_session, + refresh_token, + ) + session = getattr(response, "session", None) + if session is None: + raise _auth_error( + status_code=401, + code="AUTH_REFRESH_TOKEN_INVALID", + detail="Invalid refresh token", + ) + await asyncio.to_thread( + client.auth.set_session, + str(session.access_token), + str(session.refresh_token), + ) + await asyncio.to_thread(client.auth.sign_out) + except AuthError as exc: + logger.warning("Logout failed", error_type=type(exc).__name__) + if _is_auth_upstream_unavailable(exc): + raise _auth_error( + status_code=503, + code="AUTH_SERVICE_UNAVAILABLE", + detail=AUTH_UNAVAILABLE_DETAIL, + ) from exc + raise _auth_error( + status_code=401, + code="AUTH_REFRESH_TOKEN_INVALID", + detail="Invalid refresh token", + ) from exc + + async def get_user_by_phone(self, phone: str) -> UserByPhoneResponse: + normalized_phone = _normalize_phone(phone) + if not normalized_phone: + raise _auth_error( + status_code=404, + code="AUTH_USER_NOT_FOUND", + detail="User not found", + ) + + await self._refresh_user_lookup_cache_if_needed() + + user = self._users_by_phone.get(normalized_phone) + if user is None: + raise _auth_error( + status_code=404, + code="AUTH_USER_NOT_FOUND", + detail="User not found", + ) + + user_phone = _normalize_phone(getattr(user, "phone", "")) + if not user_phone: + raise _auth_error( + status_code=404, + code="AUTH_USER_NOT_FOUND", + detail="User not found", + ) + + return UserByPhoneResponse( + id=str(getattr(user, "id", "")), + phone=user_phone, + created_at=str(getattr(user, "created_at", "")), + phone_confirmed_at=( + str(getattr(user, "phone_confirmed_at", "")) + if getattr(user, "phone_confirmed_at", None) + else None + ), + ) + + async def get_user_by_id(self, user_id: str) -> UserByIdResponse: + users = await self.get_users_by_ids([user_id]) + resolved = users.get(user_id) + if resolved is None: + raise _auth_error( + status_code=404, + code="AUTH_USER_NOT_FOUND", + detail="User not found", + ) + return resolved + + async def get_users_by_ids( + self, user_ids: list[str] + ) -> dict[str, UserByIdResponse]: + await self._refresh_user_lookup_cache_if_needed() + resolved: dict[str, UserByIdResponse] = {} + for raw_user_id in user_ids: + normalized_user_id = raw_user_id.strip() + if not normalized_user_id: + continue + user = self._users_by_id.get(normalized_user_id) + if user is None: + continue + user_attrs = getattr(user, "user", user) + resolved[normalized_user_id] = UserByIdResponse( + id=str(getattr(user_attrs, "id", "")), + phone=getattr(user_attrs, "phone", None), + created_at=str(getattr(user_attrs, "created_at", "")), + phone_confirmed_at=( + str(getattr(user_attrs, "phone_confirmed_at", "")) + if getattr(user_attrs, "phone_confirmed_at", None) + else None + ), + ) + return resolved + + async def search_user_ids_by_phone(self, query: str, limit: int = 20) -> list[str]: + normalized_query = _normalize_phone_search_query(query) + if not normalized_query: + return [] + + await self._refresh_user_lookup_cache_if_needed() + if normalized_query.startswith("+"): + matched_user = self._users_by_phone.get(normalized_query) + if matched_user is None: + return [] + user_id = str(getattr(matched_user, "id", "")) + return [user_id] if user_id else [] + + digits = _digits_only(normalized_query) + if not digits: + return [] + + matched_records: list[tuple[str, str]] = [] + for cached_phone, candidate in self._users_by_phone.items(): + candidate_digits = _digits_only(cached_phone) + if not candidate_digits.endswith(digits): + continue + user_id = str(getattr(candidate, "id", "")) + if user_id: + matched_records.append((cached_phone, user_id)) + + if not matched_records: + return [] + + unique_ids: list[str] = [] + for _, user_id in sorted(matched_records, key=lambda item: item[0]): + if user_id in unique_ids: + continue + unique_ids.append(user_id) + if len(unique_ids) >= max(1, limit): + break + return unique_ids + + async def _refresh_user_lookup_cache_if_needed(self) -> None: + now = time.monotonic() + if now < self._user_lookup_cache_expires_at: + return + + admin_client = self._get_admin_client() + users = await asyncio.to_thread(_list_auth_users, admin_client) + users_by_phone: dict[str, Any] = {} + users_by_id: dict[str, Any] = {} + for candidate in users: + candidate_id = str(getattr(candidate, "id", "")).strip() + if candidate_id: + users_by_id[candidate_id] = candidate + candidate_phone = _normalize_phone(getattr(candidate, "phone", "")) + if candidate_phone: + users_by_phone[candidate_phone] = candidate + self._users_by_id = users_by_id + self._users_by_phone = users_by_phone + self._user_lookup_cache_expires_at = now + self._user_lookup_cache_ttl_seconds + + +def _is_auth_upstream_unavailable(exc: AuthError) -> bool: + raw_status = getattr(exc, "status", None) + if raw_status is None: + raw_status = getattr(exc, "status_code", None) + if isinstance(raw_status, int) and 500 <= raw_status < 600: + return True + + raw_code = getattr(exc, "code", None) + code = str(raw_code).lower() if raw_code is not None else "" + message = str(exc).lower() + indicators = ( + "request_timeout", + "timed out", + "timeout", + "gateway timeout", + "bad_gateway", + "service_unavailable", + "internal_server_error", + "unexpected_failure", + "upstream", + "500", + "502", + "503", + "504", + "5xx", + ) + return any(token in code or token in message for token in indicators) + + +def _map_auth_response( + response: object, failure_message: str, failure_code: str +) -> SessionResponse: + session = getattr(response, "session", None) + user = getattr(response, "user", None) + if session is None or user is None: + raise _auth_error( + status_code=401, + code=failure_code, + detail=failure_message, + ) + + phone = _normalize_phone(getattr(user, "phone", None)) + if not phone: + raise _auth_error( + status_code=401, + code=failure_code, + detail=failure_message, + ) + + try: + auth_user = AuthUser(id=str(user.id), phone=str(phone)) + except ValidationError as exc: + logger.warning( + "Auth response returned invalid phone format", + error_type=type(exc).__name__, + ) + raise _auth_error( + status_code=401, + code=failure_code, + detail=failure_message, + ) from exc + return SessionResponse( + access_token=str(session.access_token), + refresh_token=str(session.refresh_token), + expires_in=int(session.expires_in or 0), + token_type=str(session.token_type), + user=auth_user, + ) + + +def _list_auth_users(client: Any) -> list[Any]: + users: list[Any] = [] + page = 1 + max_pages = 100 + + while page <= max_pages: + response = client.auth.admin.list_users(page=page, per_page=100) + batch = ( + list(response) + if isinstance(response, list) + else list(getattr(response, "users", [])) + ) + users.extend(batch) + + if len(batch) < 100: + break + page += 1 + + return users + + +def _sanitize_phone_token(raw: object) -> str: + token = str(raw).strip() + for separator in (" ", "-", "(", ")"): + token = token.replace(separator, "") + return token + + +def _normalize_phone(raw_phone: object) -> str | None: + phone = _sanitize_phone_token(raw_phone) + if not phone: + return None + if phone.startswith("00") and len(phone) > 2: + return f"+{phone[2:]}" + if phone.startswith("+"): + return phone + if phone.isdigit(): + return f"+{phone}" + return None + + +def _normalize_phone_search_query(raw_query: str) -> str | None: + query = _sanitize_phone_token(raw_query) + if not query: + return None + if query.startswith("00") and len(query) > 2: + return f"+{query[2:]}" + if query.startswith("+"): + return query + if query.isdigit(): + return query + return None + + +def _digits_only(value: str) -> str: + return "".join(ch for ch in value if ch.isdigit()) diff --git a/backend/src/v1/auth/rate_limit.py b/backend/src/v1/auth/rate_limit.py new file mode 100644 index 0000000..e1183ba --- /dev/null +++ b/backend/src/v1/auth/rate_limit.py @@ -0,0 +1,114 @@ +from __future__ import annotations + +import asyncio +from collections import deque +from time import monotonic + +from core.http.errors import ApiProblemError + +from core.logging import get_logger +from services.base.redis import get_or_init_redis_client + +_BUCKETS: dict[str, deque[float]] = {} +_LAST_SEEN: dict[str, float] = {} +_LOCK = asyncio.Lock() +_CLEANUP_INTERVAL = 200 +_CALL_COUNT = 0 +logger = get_logger("v1.auth.rate_limit") +_REDIS_LIMIT_SCRIPT = """ +local current = redis.call("INCR", KEYS[1]) +if current == 1 then + redis.call("EXPIRE", KEYS[1], ARGV[1]) +end +return current +""" + + +async def enforce_rate_limit( + *, + scope: str, + identifier: str, + limit: int, + window_seconds: int, +) -> None: + key = f"auth:rate_limit:{scope}:{identifier.lower()}" + try: + await _enforce_rate_limit_with_redis( + key=key, + limit=limit, + window_seconds=window_seconds, + ) + return + except ApiProblemError: + raise + except Exception as exc: # noqa: BLE001 + logger.warning( + "Rate limit fallback to in-memory", + scope=scope, + error_type=type(exc).__name__, + ) + await _enforce_rate_limit_in_memory( + key=key, + limit=limit, + window_seconds=window_seconds, + ) + + +async def _enforce_rate_limit_with_redis( + *, + key: str, + limit: int, + window_seconds: int, +) -> None: + client = await get_or_init_redis_client() + current = await client.eval(_REDIS_LIMIT_SCRIPT, 1, key, window_seconds) # type: ignore[await] + if int(current) > limit: + raise ApiProblemError( + status_code=429, + code="AUTH_TOO_MANY_REQUESTS", + detail="Too many requests", + ) + + +async def _enforce_rate_limit_in_memory( + *, + key: str, + limit: int, + window_seconds: int, +) -> None: + global _CALL_COUNT + now = monotonic() + async with _LOCK: + bucket = _BUCKETS.setdefault(key, deque()) + _LAST_SEEN[key] = now + cutoff = now - float(window_seconds) + while bucket and bucket[0] <= cutoff: + bucket.popleft() + if len(bucket) >= limit: + raise ApiProblemError( + status_code=429, + code="AUTH_TOO_MANY_REQUESTS", + detail="Too many requests", + ) + bucket.append(now) + _CALL_COUNT += 1 + if _CALL_COUNT % _CLEANUP_INTERVAL == 0: + _cleanup_stale_buckets(now) + + +def _cleanup_stale_buckets(now: float) -> None: + stale_keys = [ + key + for key, last_seen in _LAST_SEEN.items() + if key not in _BUCKETS or (not _BUCKETS[key] and now - last_seen > 3600) + ] + for key in stale_keys: + _BUCKETS.pop(key, None) + _LAST_SEEN.pop(key, None) + + +def reset_rate_limit_state() -> None: + _BUCKETS.clear() + _LAST_SEEN.clear() + global _CALL_COUNT + _CALL_COUNT = 0 diff --git a/backend/src/v1/auth/registration_bootstrap.py b/backend/src/v1/auth/registration_bootstrap.py new file mode 100644 index 0000000..d5de7f4 --- /dev/null +++ b/backend/src/v1/auth/registration_bootstrap.py @@ -0,0 +1,239 @@ +from __future__ import annotations + +from datetime import UTC, datetime, time, timedelta +from typing import Protocol +from uuid import UUID, uuid4 +from zoneinfo import ZoneInfo, ZoneInfoNotFoundError + +from sqlalchemy import select +from sqlalchemy.dialects.postgresql import insert +from sqlalchemy.ext.asyncio import AsyncSession + +from core.logging import get_logger +from models.automation_jobs import AutomationJob +from schemas.enums import AutomationJobStatus, MemoryType, ScheduleType +from models.profile import Profile +from schemas.domain.automation import AutomationJobConfig, ScheduleConfig +from schemas.domain.memory_content import UserMemoryContent, WorkProfileContent +from schemas.shared.user import parse_profile_settings +from v1.auth.automation_static_config import load_static_automation_job_config +from v1.auth.schemas import RegistrationBootstrapRequest +from v1.memories.repository import SQLAlchemyMemoriesRepository + +logger = get_logger("v1.auth.registration_bootstrap") + + +class RegistrationBootstrapRepository: + def __init__(self, session: AsyncSession) -> None: + self._session = session + self._memories_repository = SQLAlchemyMemoriesRepository(session) + + async def get_profile_timezone(self, *, user_id: UUID) -> str: + stmt = select(Profile.settings).where(Profile.id == user_id) + settings = (await self._session.execute(stmt)).scalar_one_or_none() + parsed = parse_profile_settings( + settings if isinstance(settings, dict) else None + ) + return parsed.preferences.timezone + + async def insert_bootstrap_automation_job_if_absent( + self, + *, + owner_id: UUID, + bootstrap_key: str, + title: str, + config: AutomationJobConfig, + timezone_name: str, + next_run_at: datetime, + ) -> bool: + stmt = ( + insert(AutomationJob) + .values( + id=uuid4(), + owner_id=owner_id, + bootstrap_key=bootstrap_key, + title=title, + config=config.model_dump(mode="json"), + next_run_at=next_run_at, + timezone=timezone_name, + status=AutomationJobStatus.ACTIVE, + created_by=owner_id, + ) + .on_conflict_do_nothing( + index_elements=["owner_id", "bootstrap_key"], + index_where=AutomationJob.deleted_at.is_(None) + & AutomationJob.bootstrap_key.is_not(None), + ) + .returning(AutomationJob.id) + ) + inserted_id = (await self._session.execute(stmt)).scalar_one_or_none() + await self._session.flush() + return inserted_id is not None + + async def upsert_initial_memory( + self, + *, + owner_id: UUID, + memory_type: MemoryType, + content: dict, + ) -> bool: + return await self._memories_repository.create_if_absent( + owner_id=owner_id, + memory_type=memory_type, + content=content, + ) + + +class RegistrationBootstrapper(Protocol): + async def ensure_user_automation_jobs(self, *, user_id: str | UUID) -> None: ... + + +class RegistrationBootstrapRepositoryLike(Protocol): + async def get_profile_timezone(self, *, user_id: UUID) -> str: ... + + async def insert_bootstrap_automation_job_if_absent( + self, + *, + owner_id: UUID, + bootstrap_key: str, + title: str, + config: AutomationJobConfig, + timezone_name: str, + next_run_at: datetime, + ) -> bool: ... + + async def upsert_initial_memory( + self, + *, + owner_id: UUID, + memory_type: MemoryType, + content: dict, + ) -> bool: ... + + +class SessionLike(Protocol): + async def commit(self) -> None: ... + + async def rollback(self) -> None: ... + + +def compute_first_run_at_utc( + *, + now_utc: datetime, + timezone_name: str, + schedule: ScheduleConfig, +) -> datetime: + try: + timezone_obj = ZoneInfo(timezone_name) + except ZoneInfoNotFoundError: + timezone_obj = ZoneInfo("UTC") + + local_now = now_utc.astimezone(timezone_obj) + run_clock = time( + hour=schedule.run_at.hour, + minute=schedule.run_at.minute, + tzinfo=timezone_obj, + ) + + if schedule.type == ScheduleType.DAILY: + candidate_local = datetime.combine(local_now.date(), run_clock) + if candidate_local <= local_now: + candidate_local = candidate_local + timedelta(days=1) + return candidate_local.astimezone(UTC) + + weekdays = schedule.weekdays or [] + if not weekdays: + raise ValueError("weekly schedule requires weekdays") + + normalized_weekdays = sorted(set(weekdays)) + for day_offset in range(0, 8): + candidate_day = local_now.date() + timedelta(days=day_offset) + if candidate_day.isoweekday() not in normalized_weekdays: + continue + candidate_local = datetime.combine(candidate_day, run_clock) + if candidate_local > local_now: + return candidate_local.astimezone(UTC) + + fallback_day = local_now.date() + timedelta(days=7) + while fallback_day.isoweekday() not in normalized_weekdays: + fallback_day = fallback_day + timedelta(days=1) + fallback_local = datetime.combine(fallback_day, run_clock) + return fallback_local.astimezone(UTC) + + +class RegistrationAutomationBootstrapService: + def __init__( + self, + *, + repository: RegistrationBootstrapRepositoryLike, + session: SessionLike, + ) -> None: + self._repository = repository + self._session = session + + async def ensure_user_automation_jobs(self, *, user_id: str | UUID) -> None: + request = RegistrationBootstrapRequest.model_validate({"user_id": user_id}) + owner_id = request.user_id + timezone_name = await self._repository.get_profile_timezone(user_id=owner_id) + + definitions = [ + { + "bootstrap_key": "memory_extraction", + "config_name": "memory_extraction", + "title": "记忆推送", + } + ] + + try: + inserted_any = False + created_or_updated_memory = False + + user_initialized = await self._repository.upsert_initial_memory( + owner_id=owner_id, + memory_type=MemoryType.USER, + content=UserMemoryContent().model_dump(mode="json"), + ) + work_initialized = await self._repository.upsert_initial_memory( + owner_id=owner_id, + memory_type=MemoryType.WORK, + content=WorkProfileContent().model_dump(mode="json"), + ) + created_or_updated_memory = user_initialized or work_initialized + + for definition in definitions: + bootstrap_key = str(definition["bootstrap_key"]) + job_config = load_static_automation_job_config( + config_name=str(definition["config_name"]) + ) + schedule = job_config.schedule + if schedule is None: + raise ValueError( + f"bootstrap job {bootstrap_key} has no schedule configured" + ) + next_run_at = compute_first_run_at_utc( + now_utc=datetime.now(UTC), + timezone_name=timezone_name, + schedule=schedule, + ) + inserted = ( + await self._repository.insert_bootstrap_automation_job_if_absent( + owner_id=owner_id, + bootstrap_key=bootstrap_key, + title=str(definition["title"]), + config=job_config, + timezone_name=timezone_name, + next_run_at=next_run_at, + ) + ) + inserted_any = inserted_any or inserted + if inserted_any or created_or_updated_memory: + await self._session.commit() + logger.info( + "user automation jobs bootstrapped", + user_id=user_id, + timezone=timezone_name, + memory_initialized=created_or_updated_memory, + ) + except Exception: + await self._session.rollback() + raise diff --git a/backend/src/v1/auth/router.py b/backend/src/v1/auth/router.py new file mode 100644 index 0000000..2cad943 --- /dev/null +++ b/backend/src/v1/auth/router.py @@ -0,0 +1,117 @@ +from __future__ import annotations + +from fastapi import APIRouter, Depends, Request, Response + +from core.config.settings import config +from v1.auth.rate_limit import enforce_rate_limit +from v1.auth.dependencies import get_auth_service +from v1.auth.schemas import ( + OtpSendRequest, + PhoneSessionCreateRequest, + SessionDeleteRequest, + SessionRefreshRequest, + SessionResponse, +) +from v1.auth.service import AuthService + + +router = APIRouter(prefix="/auth", tags=["auth"]) + + +@router.post("/otp/send", status_code=204) +async def send_otp( + payload: OtpSendRequest, + request: Request, + service: AuthService = Depends(get_auth_service), +) -> Response: + client_ip = _client_ip(request) + await enforce_rate_limit( + scope="otp_send_phone", + identifier=payload.phone, + limit=3, + window_seconds=60, + ) + await enforce_rate_limit( + scope="otp_send_ip", + identifier=client_ip, + limit=20, + window_seconds=60, + ) + await service.send_otp(payload) + return Response(status_code=204) + + +@router.post("/phone-session", response_model=SessionResponse) +async def create_phone_session( + payload: PhoneSessionCreateRequest, + request: Request, + service: AuthService = Depends(get_auth_service), +) -> SessionResponse: + client_ip = _client_ip(request) + await enforce_rate_limit( + scope="phone_session_phone", + identifier=payload.phone, + limit=6, + window_seconds=300, + ) + await enforce_rate_limit( + scope="phone_session_ip", + identifier=client_ip, + limit=20, + window_seconds=300, + ) + return await service.create_phone_session(payload) + + +@router.post("/sessions/refresh", response_model=SessionResponse) +async def refresh_session( + payload: SessionRefreshRequest, + request: Request, + service: AuthService = Depends(get_auth_service), +) -> SessionResponse: + await enforce_rate_limit( + scope="refresh", + identifier=_client_ip(request), + limit=10, + window_seconds=60, + ) + return await service.refresh_session(payload) + + +@router.delete("/sessions", status_code=204) +async def delete_session( + payload: SessionDeleteRequest, + request: Request, + service: AuthService = Depends(get_auth_service), +) -> Response: + await enforce_rate_limit( + scope="logout", + identifier=_client_ip(request), + limit=10, + window_seconds=60, + ) + await service.delete_session(payload.refresh_token) + return Response(status_code=204) + + +def _client_ip(request: Request) -> str: + host = request.client.host if request.client else "" + if not host: + return "unknown" + + if _should_trust_proxy_headers(host): + forwarded_for = request.headers.get("x-forwarded-for", "") + if forwarded_for: + first = forwarded_for.split(",")[0].strip() + if first: + return first + real_ip = request.headers.get("x-real-ip", "").strip() + if real_ip: + return real_ip + + return host + + +def _should_trust_proxy_headers(host: str) -> bool: + trusted_proxies = {entry.strip() for entry in config.runtime.trusted_proxy_ips} + return host in trusted_proxies diff --git a/backend/src/v1/auth/schemas.py b/backend/src/v1/auth/schemas.py new file mode 100644 index 0000000..446283a --- /dev/null +++ b/backend/src/v1/auth/schemas.py @@ -0,0 +1,66 @@ +from __future__ import annotations + +from uuid import UUID + +from pydantic import BaseModel, ConfigDict, Field + +SUPABASE_PASSWORD_MIN_LENGTH = 6 +SUPABASE_PHONE_PATTERN = r"^\+[1-9]\d{7,14}$" + + +class OtpSendRequest(BaseModel): + model_config = ConfigDict(extra="forbid") + + phone: str = Field(pattern=SUPABASE_PHONE_PATTERN) + + +class PhoneSessionCreateRequest(BaseModel): + model_config = ConfigDict(extra="forbid") + + phone: str = Field(pattern=SUPABASE_PHONE_PATTERN) + token: str = Field(pattern=r"^\d{6}$") + + +class SessionRefreshRequest(BaseModel): + refresh_token: str = Field(min_length=1) + + +class SessionDeleteRequest(BaseModel): + refresh_token: str = Field(min_length=1) + + +class AuthUser(BaseModel): + id: str + phone: str = Field(pattern=SUPABASE_PHONE_PATTERN) + + +class SessionResponse(BaseModel): + access_token: str + refresh_token: str + expires_in: int + token_type: str + user: AuthUser + + +class UserByPhoneResponse(BaseModel): + id: str + phone: str = Field(pattern=SUPABASE_PHONE_PATTERN) + created_at: str + phone_confirmed_at: str | None = None + + +class UserByIdResponse(BaseModel): + id: str + phone: str | None = None + created_at: str + phone_confirmed_at: str | None = None + + +class OtpSendResponse(BaseModel): + phone: str = Field(pattern=SUPABASE_PHONE_PATTERN) + + +class RegistrationBootstrapRequest(BaseModel): + model_config = ConfigDict(extra="forbid") + + user_id: UUID diff --git a/backend/src/v1/auth/service.py b/backend/src/v1/auth/service.py new file mode 100644 index 0000000..d16e2ce --- /dev/null +++ b/backend/src/v1/auth/service.py @@ -0,0 +1,63 @@ +from __future__ import annotations + +from typing import Protocol + +from v1.auth.schemas import ( + OtpSendRequest, + PhoneSessionCreateRequest, + SessionRefreshRequest, + SessionResponse, +) + + +class AuthServiceGateway(Protocol): + async def send_otp(self, request: OtpSendRequest) -> None: + raise NotImplementedError + + async def create_phone_session( + self, request: PhoneSessionCreateRequest + ) -> SessionResponse: + raise NotImplementedError + + async def refresh_session(self, request: SessionRefreshRequest) -> SessionResponse: + raise NotImplementedError + + async def delete_session(self, refresh_token: str | None) -> None: + raise NotImplementedError + + +class AuthService: + _gateway: AuthServiceGateway + _registration_bootstrapper: RegistrationBootstrapper | None + + def __init__( + self, + gateway: AuthServiceGateway, + registration_bootstrapper: "RegistrationBootstrapper | None" = None, + ) -> None: + self._gateway = gateway + self._registration_bootstrapper = registration_bootstrapper + + async def send_otp(self, request: OtpSendRequest) -> None: + await self._gateway.send_otp(request) + + async def create_phone_session( + self, request: PhoneSessionCreateRequest + ) -> SessionResponse: + response = await self._gateway.create_phone_session(request) + if self._registration_bootstrapper is not None: + await self._registration_bootstrapper.ensure_user_automation_jobs( + user_id=response.user.id + ) + return response + + async def refresh_session(self, request: SessionRefreshRequest) -> SessionResponse: + return await self._gateway.refresh_session(request) + + async def delete_session(self, refresh_token: str | None) -> None: + await self._gateway.delete_session(refresh_token) + + +class RegistrationBootstrapper(Protocol): + async def ensure_user_automation_jobs(self, *, user_id: str) -> None: + raise NotImplementedError diff --git a/docs/reference/backend-features.md b/docs/references/backend-features.md similarity index 100% rename from docs/reference/backend-features.md rename to docs/references/backend-features.md diff --git a/infra/docker/docker-compose.yml b/infra/docker/docker-compose.yml index 70ec29e..329213c 100644 --- a/infra/docker/docker-compose.yml +++ b/infra/docker/docker-compose.yml @@ -1,5 +1,8 @@ name: eryao-local +include: + - ./supabase/docker-compose.yml + services: redis: image: redis:7-alpine diff --git a/infra/docker/supabase/docker-compose.yml b/infra/docker/supabase/docker-compose.yml new file mode 100644 index 0000000..add41e1 --- /dev/null +++ b/infra/docker/supabase/docker-compose.yml @@ -0,0 +1,214 @@ +name: supabase + +services: + db: + container_name: supabase-db + image: supabase/postgres:15.8.1.085 + restart: unless-stopped + volumes: + - ./volumes/db/webhooks.sql:/docker-entrypoint-initdb.d/init-scripts/98-webhooks.sql:ro + - ./volumes/db/roles.sql:/docker-entrypoint-initdb.d/init-scripts/99-roles.sql:ro + - ./volumes/db/jwt.sql:/docker-entrypoint-initdb.d/init-scripts/99-jwt.sql:ro + - ./volumes/db/_supabase.sql:/docker-entrypoint-initdb.d/migrations/97-_supabase.sql:ro + - ./volumes/db/local-dev-grants.sql:/docker-entrypoint-initdb.d/init-scripts/100-local-dev-grants.sql:ro + - ./volumes/db/data:/var/lib/postgresql/data + - db-config:/etc/postgresql-custom + healthcheck: + test: ["CMD", "pg_isready", "-U", "postgres", "-h", "localhost"] + interval: 5s + timeout: 5s + retries: 10 + environment: + POSTGRES_HOST: /var/run/postgresql + PGPORT: 5432 + POSTGRES_PORT: 5432 + PGPASSWORD: ${ERYAO_DATABASE__PASSWORD} + POSTGRES_PASSWORD: ${ERYAO_DATABASE__PASSWORD} + PGDATABASE: ${ERYAO_DATABASE__NAME:-eryao} + POSTGRES_DB: ${ERYAO_DATABASE__NAME:-eryao} + JWT_SECRET: ${ERYAO_SUPABASE__JWT_SECRET} + JWT_EXP: 3600 + command: ["postgres", "-c", "config_file=/etc/postgresql/postgresql.conf", "-c", "log_min_messages=fatal"] + ports: + - 127.0.0.1:${ERYAO_DATABASE__PORT:-5432}:5432 + + auth: + container_name: supabase-auth + image: supabase/gotrue:v2.186.0 + restart: unless-stopped + depends_on: + db: + condition: service_healthy + healthcheck: + test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost:9999/health"] + interval: 5s + timeout: 5s + retries: 3 + environment: + GOTRUE_API_HOST: 0.0.0.0 + GOTRUE_API_PORT: 9999 + API_EXTERNAL_URL: ${ERYAO_SUPABASE__PUBLIC_URL:-http://localhost:8001} + GOTRUE_DB_DRIVER: postgres + GOTRUE_DB_DATABASE_URL: postgres://supabase_auth_admin:${ERYAO_DATABASE__PASSWORD}@db:5432/${ERYAO_DATABASE__NAME:-eryao} + GOTRUE_SITE_URL: http://localhost:3000 + GOTRUE_URI_ALLOW_LIST: "" + GOTRUE_DISABLE_SIGNUP: "false" + GOTRUE_JWT_ADMIN_ROLES: service_role + GOTRUE_JWT_AUD: authenticated + GOTRUE_JWT_DEFAULT_GROUP_NAME: authenticated + GOTRUE_JWT_EXP: 3600 + GOTRUE_JWT_SECRET: ${ERYAO_SUPABASE__JWT_SECRET} + GOTRUE_MAILER_AUTOCONFIRM: "false" + GOTRUE_SMTP_ADMIN_EMAIL: dev@example.com + GOTRUE_SMTP_HOST: localhost + GOTRUE_SMTP_PORT: 2500 + GOTRUE_SMTP_USER: disabled + GOTRUE_SMTP_PASS: disabled + GOTRUE_SMTP_SENDER_NAME: disabled + GOTRUE_MAILER_URLPATHS_INVITE: /auth/v1/verify + GOTRUE_MAILER_URLPATHS_CONFIRMATION: /auth/v1/verify + GOTRUE_MAILER_URLPATHS_RECOVERY: /auth/v1/verify + GOTRUE_MAILER_URLPATHS_EMAIL_CHANGE: /auth/v1/verify + + rest: + container_name: supabase-rest + image: postgrest/postgrest:v14.6 + restart: unless-stopped + depends_on: + db: + condition: service_healthy + environment: + PGRST_DB_URI: postgres://authenticator:${ERYAO_DATABASE__PASSWORD}@db:5432/${ERYAO_DATABASE__NAME:-eryao} + PGRST_DB_SCHEMAS: public,storage,graphql_public + PGRST_DB_MAX_ROWS: 1000 + PGRST_DB_EXTRA_SEARCH_PATH: public + PGRST_DB_ANON_ROLE: anon + PGRST_JWT_SECRET: ${ERYAO_SUPABASE__JWT_SECRET} + PGRST_DB_USE_LEGACY_GUCS: "false" + PGRST_APP_SETTINGS_JWT_SECRET: ${ERYAO_SUPABASE__JWT_SECRET} + PGRST_APP_SETTINGS_JWT_EXP: 3600 + + storage: + container_name: supabase-storage + image: supabase/storage-api:v1.44.2 + restart: unless-stopped + depends_on: + db: + condition: service_healthy + rest: + condition: service_started + healthcheck: + test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://storage:5000/status"] + interval: 5s + timeout: 5s + retries: 3 + start_period: 10s + environment: + ANON_KEY: ${ERYAO_SUPABASE__ANON_KEY} + SERVICE_KEY: ${ERYAO_SUPABASE__SERVICE_ROLE_KEY} + POSTGREST_URL: http://rest:3000 + AUTH_JWT_SECRET: ${ERYAO_SUPABASE__JWT_SECRET} + DATABASE_URL: postgres://supabase_storage_admin:${ERYAO_DATABASE__PASSWORD}@db:5432/${ERYAO_DATABASE__NAME:-eryao} + STORAGE_PUBLIC_URL: ${ERYAO_SUPABASE__PUBLIC_URL:-http://localhost:8001} + REQUEST_ALLOW_X_FORWARDED_PATH: "true" + FILE_SIZE_LIMIT: 52428800 + STORAGE_BACKEND: file + GLOBAL_S3_BUCKET: ${ERYAO_STORAGE__ATTACHMENT__BUCKET:-agent-chat-attachments} + FILE_STORAGE_BACKEND_PATH: /var/lib/storage + TENANT_ID: local + REGION: local + ENABLE_IMAGE_TRANSFORMATION: "false" + volumes: + - ./volumes/storage:/var/lib/storage + + meta: + container_name: supabase-meta + image: supabase/postgres-meta:v0.95.2 + restart: unless-stopped + depends_on: + db: + condition: service_healthy + environment: + PG_META_PORT: 8080 + PG_META_DB_HOST: db + PG_META_DB_PORT: 5432 + PG_META_DB_NAME: ${ERYAO_DATABASE__NAME:-eryao} + PG_META_DB_USER: postgres + PG_META_DB_PASSWORD: ${ERYAO_DATABASE__PASSWORD} + PG_META_DB_SSL_MODE: disable + healthcheck: + test: ["CMD", "/bin/sh", "-c", "exit 0"] + interval: 10s + timeout: 5s + retries: 1 + + studio: + container_name: supabase-studio + image: supabase/studio:2026.03.16-sha-5528817 + restart: unless-stopped + depends_on: + meta: + condition: service_healthy + environment: + STUDIO_PG_META_URL: http://meta:8080 + POSTGRES_PASSWORD: ${ERYAO_DATABASE__PASSWORD} + POSTGRES_HOST: db + POSTGRES_PORT: 5432 + POSTGRES_DB: ${ERYAO_DATABASE__NAME:-eryao} + POSTGRES_USER: supabase_admin + DEFAULT_ORGANIZATION_NAME: Default Organization + DEFAULT_PROJECT_NAME: Default Project + SUPABASE_URL: http://kong:8000 + SUPABASE_PUBLIC_URL: ${ERYAO_SUPABASE__PUBLIC_URL:-http://localhost:8001} + SUPABASE_ANON_KEY: ${ERYAO_SUPABASE__ANON_KEY} + SUPABASE_SERVICE_KEY: ${ERYAO_SUPABASE__SERVICE_ROLE_KEY} + AUTH_JWT_SECRET: ${ERYAO_SUPABASE__JWT_SECRET} + EDGE_FUNCTIONS_MANAGEMENT_FOLDER: /tmp/functions + LOGFLARE_API_KEY: local-logflare-public-token + LOGFLARE_URL: http://localhost:4000 + NEXT_PUBLIC_ENABLE_LOGS: "false" + + kong: + container_name: supabase-kong + image: kong/kong:3.9.1 + restart: unless-stopped + depends_on: + auth: + condition: service_healthy + rest: + condition: service_started + storage: + condition: service_healthy + studio: + condition: service_started + meta: + condition: service_healthy + healthcheck: + test: ["CMD", "kong", "health"] + interval: 5s + timeout: 5s + retries: 5 + ports: + - 127.0.0.1:8001:8000/tcp + - 127.0.0.1:8443:8443/tcp + volumes: + - ./volumes/api/kong.yml:/home/kong/temp.yml:ro + - ./volumes/api/kong-entrypoint.sh:/home/kong/kong-entrypoint.sh:ro + environment: + KONG_DATABASE: "off" + KONG_DECLARATIVE_CONFIG: /usr/local/kong/kong.yml + KONG_DNS_ORDER: LAST,A,CNAME + KONG_DNS_NOT_FOUND_TTL: 1 + KONG_PLUGINS: request-transformer,cors,key-auth,acl,post-function,basic-auth,ip-restriction + SUPABASE_ANON_KEY: ${ERYAO_SUPABASE__ANON_KEY} + SUPABASE_SERVICE_KEY: ${ERYAO_SUPABASE__SERVICE_ROLE_KEY} + SUPABASE_PUBLISHABLE_KEY: "" + SUPABASE_SECRET_KEY: "" + ANON_KEY_ASYMMETRIC: "" + SERVICE_ROLE_KEY_ASYMMETRIC: "" + DASHBOARD_USERNAME: localadmin + DASHBOARD_PASSWORD: LocalAdmin-Change-This-Now + entrypoint: /home/kong/kong-entrypoint.sh + +volumes: + db-config: diff --git a/infra/docker/supabase/volumes/api/kong-entrypoint.sh b/infra/docker/supabase/volumes/api/kong-entrypoint.sh new file mode 100755 index 0000000..176f058 --- /dev/null +++ b/infra/docker/supabase/volumes/api/kong-entrypoint.sh @@ -0,0 +1,26 @@ +#!/bin/bash + +if [ -n "$SUPABASE_SECRET_KEY" ] && [ -n "$SUPABASE_PUBLISHABLE_KEY" ]; then + export LUA_AUTH_EXPR="\$((headers.authorization ~= nil and headers.authorization:sub(1, 10) ~= 'Bearer sb_' and headers.authorization) or (headers.apikey == '$SUPABASE_SECRET_KEY' and 'Bearer $SERVICE_ROLE_KEY_ASYMMETRIC') or (headers.apikey == '$SUPABASE_PUBLISHABLE_KEY' and 'Bearer $ANON_KEY_ASYMMETRIC') or headers.apikey)" +else + export LUA_AUTH_EXPR="\$((headers.authorization ~= nil and headers.authorization:sub(1, 10) ~= 'Bearer sb_' and headers.authorization) or headers.apikey)" +fi + +awk '{ + result = "" + rest = $0 + while (match(rest, /\$[A-Za-z_][A-Za-z_0-9]*/)) { + varname = substr(rest, RSTART + 1, RLENGTH - 1) + if (varname in ENVIRON) { + result = result substr(rest, 1, RSTART - 1) ENVIRON[varname] + } else { + result = result substr(rest, 1, RSTART + RLENGTH - 1) + } + rest = substr(rest, RSTART + RLENGTH) + } + print result rest +}' /home/kong/temp.yml > "$KONG_DECLARATIVE_CONFIG" + +sed -i '/^[[:space:]]*- key:[[:space:]]*$/d' "$KONG_DECLARATIVE_CONFIG" + +exec /entrypoint.sh kong docker-start diff --git a/infra/docker/supabase/volumes/api/kong.yml b/infra/docker/supabase/volumes/api/kong.yml new file mode 100644 index 0000000..b2356a3 --- /dev/null +++ b/infra/docker/supabase/volumes/api/kong.yml @@ -0,0 +1,177 @@ +_format_version: '2.1' +_transform: true + +consumers: + - username: DASHBOARD + - username: anon + keyauth_credentials: + - key: $SUPABASE_ANON_KEY + - username: service_role + keyauth_credentials: + - key: $SUPABASE_SERVICE_KEY + +acls: + - consumer: anon + group: anon + - consumer: service_role + group: admin + +basicauth_credentials: + - consumer: DASHBOARD + username: "$DASHBOARD_USERNAME" + password: "$DASHBOARD_PASSWORD" + +services: + - name: auth-v1-open + url: http://auth:9999/verify + routes: + - name: auth-v1-open + strip_path: true + paths: + - /auth/v1/verify + plugins: + - name: cors + + - name: auth-v1-open-callback + url: http://auth:9999/callback + routes: + - name: auth-v1-open-callback + strip_path: true + paths: + - /auth/v1/callback + plugins: + - name: cors + + - name: auth-v1-open-jwks + url: http://auth:9999/.well-known/jwks.json + routes: + - name: auth-v1-open-jwks + strip_path: true + paths: + - /auth/v1/.well-known/jwks.json + plugins: + - name: cors + + - name: auth-v1 + url: http://auth:9999/ + routes: + - name: auth-v1-all + strip_path: true + paths: + - /auth/v1/ + plugins: + - name: cors + - name: key-auth + - name: request-transformer + config: + add: + headers: + - "Authorization: $LUA_AUTH_EXPR" + replace: + headers: + - "Authorization: $LUA_AUTH_EXPR" + - name: acl + config: + allow: + - admin + - anon + + - name: rest-v1 + url: http://rest:3000/ + routes: + - name: rest-v1-all + strip_path: true + paths: + - /rest/v1/ + plugins: + - name: cors + - name: key-auth + - name: request-transformer + config: + add: + headers: + - "Authorization: $LUA_AUTH_EXPR" + replace: + headers: + - "Authorization: $LUA_AUTH_EXPR" + - name: acl + config: + allow: + - admin + - anon + + - name: storage-v1 + url: http://storage:5000/ + routes: + - name: storage-v1-all + strip_path: true + paths: + - /storage/v1/ + plugins: + - name: cors + - name: request-transformer + config: + add: + headers: + - "Authorization: $LUA_AUTH_EXPR" + replace: + headers: + - "Authorization: $LUA_AUTH_EXPR" + - name: post-function + config: + access: + - | + local auth = kong.request.get_header("authorization") + if auth == nil or auth == "" or auth:find("^%s*$") then + kong.service.request.clear_header("authorization") + end + + - name: meta + url: http://meta:8080/ + routes: + - name: meta-all + strip_path: true + paths: + - /pg/ + plugins: + - name: key-auth + - name: acl + config: + allow: + - admin + + - name: dashboard + url: http://studio:3000/ + routes: + - name: dashboard-all + strip_path: true + paths: + - / + plugins: + - name: cors + - name: basic-auth + config: + hide_credentials: true + + - name: mcp + _comment: 'MCP: /mcp -> http://studio:3000/api/mcp' + url: http://studio:3000/api/mcp + routes: + - name: mcp + strip_path: true + paths: + - /mcp + plugins: + - name: cors + - name: ip-restriction + config: + allow: + - 127.0.0.1 + - ::1 + - 172.17.0.1 + - 172.18.0.1 + - 172.19.0.1 + - 172.20.0.1 + - 172.21.0.1 + - 172.22.0.1 + deny: [] diff --git a/infra/docker/supabase/volumes/db/_supabase.sql b/infra/docker/supabase/volumes/db/_supabase.sql new file mode 100644 index 0000000..6236ae1 --- /dev/null +++ b/infra/docker/supabase/volumes/db/_supabase.sql @@ -0,0 +1,3 @@ +\set pguser `echo "$POSTGRES_USER"` + +CREATE DATABASE _supabase WITH OWNER :pguser; diff --git a/infra/docker/supabase/volumes/db/jwt.sql b/infra/docker/supabase/volumes/db/jwt.sql new file mode 100644 index 0000000..cfd3b16 --- /dev/null +++ b/infra/docker/supabase/volumes/db/jwt.sql @@ -0,0 +1,5 @@ +\set jwt_secret `echo "$JWT_SECRET"` +\set jwt_exp `echo "$JWT_EXP"` + +ALTER DATABASE postgres SET "app.settings.jwt_secret" TO :'jwt_secret'; +ALTER DATABASE postgres SET "app.settings.jwt_exp" TO :'jwt_exp'; diff --git a/infra/docker/supabase/volumes/db/local-dev-grants.sql b/infra/docker/supabase/volumes/db/local-dev-grants.sql new file mode 100644 index 0000000..0d3116a --- /dev/null +++ b/infra/docker/supabase/volumes/db/local-dev-grants.sql @@ -0,0 +1,2 @@ +grant usage on schema public to postgres; +grant create on schema public to postgres; diff --git a/infra/docker/supabase/volumes/db/roles.sql b/infra/docker/supabase/volumes/db/roles.sql new file mode 100644 index 0000000..db3d152 --- /dev/null +++ b/infra/docker/supabase/volumes/db/roles.sql @@ -0,0 +1,9 @@ +-- NOTE: change to your own passwords for production environments +\set pgpass `echo "$POSTGRES_PASSWORD"` + +ALTER USER authenticator WITH PASSWORD :'pgpass'; +ALTER USER pgbouncer WITH PASSWORD :'pgpass'; +ALTER USER supabase_auth_admin WITH PASSWORD :'pgpass'; +ALTER USER supabase_functions_admin WITH PASSWORD :'pgpass'; +ALTER USER supabase_storage_admin WITH PASSWORD :'pgpass'; +ALTER USER supabase_read_only_user WITH PASSWORD :'pgpass'; diff --git a/infra/docker/supabase/volumes/db/webhooks.sql b/infra/docker/supabase/volumes/db/webhooks.sql new file mode 100644 index 0000000..5837b86 --- /dev/null +++ b/infra/docker/supabase/volumes/db/webhooks.sql @@ -0,0 +1,208 @@ +BEGIN; + -- Create pg_net extension + CREATE EXTENSION IF NOT EXISTS pg_net SCHEMA extensions; + -- Create supabase_functions schema + CREATE SCHEMA supabase_functions AUTHORIZATION supabase_admin; + GRANT USAGE ON SCHEMA supabase_functions TO postgres, anon, authenticated, service_role; + ALTER DEFAULT PRIVILEGES IN SCHEMA supabase_functions GRANT ALL ON TABLES TO postgres, anon, authenticated, service_role; + ALTER DEFAULT PRIVILEGES IN SCHEMA supabase_functions GRANT ALL ON FUNCTIONS TO postgres, anon, authenticated, service_role; + ALTER DEFAULT PRIVILEGES IN SCHEMA supabase_functions GRANT ALL ON SEQUENCES TO postgres, anon, authenticated, service_role; + -- supabase_functions.migrations definition + CREATE TABLE supabase_functions.migrations ( + version text PRIMARY KEY, + inserted_at timestamptz NOT NULL DEFAULT NOW() + ); + -- Initial supabase_functions migration + INSERT INTO supabase_functions.migrations (version) VALUES ('initial'); + -- supabase_functions.hooks definition + CREATE TABLE supabase_functions.hooks ( + id bigserial PRIMARY KEY, + hook_table_id integer NOT NULL, + hook_name text NOT NULL, + created_at timestamptz NOT NULL DEFAULT NOW(), + request_id bigint + ); + CREATE INDEX supabase_functions_hooks_request_id_idx ON supabase_functions.hooks USING btree (request_id); + CREATE INDEX supabase_functions_hooks_h_table_id_h_name_idx ON supabase_functions.hooks USING btree (hook_table_id, hook_name); + COMMENT ON TABLE supabase_functions.hooks IS 'Supabase Functions Hooks: Audit trail for triggered hooks.'; + CREATE FUNCTION supabase_functions.http_request() + RETURNS trigger + LANGUAGE plpgsql + AS $function$ + DECLARE + request_id bigint; + payload jsonb; + url text := TG_ARGV[0]::text; + method text := TG_ARGV[1]::text; + headers jsonb DEFAULT '{}'::jsonb; + params jsonb DEFAULT '{}'::jsonb; + timeout_ms integer DEFAULT 1000; + BEGIN + IF url IS NULL OR url = 'null' THEN + RAISE EXCEPTION 'url argument is missing'; + END IF; + + IF method IS NULL OR method = 'null' THEN + RAISE EXCEPTION 'method argument is missing'; + END IF; + + IF TG_ARGV[2] IS NULL OR TG_ARGV[2] = 'null' THEN + headers = '{"Content-Type": "application/json"}'::jsonb; + ELSE + headers = TG_ARGV[2]::jsonb; + END IF; + + IF TG_ARGV[3] IS NULL OR TG_ARGV[3] = 'null' THEN + params = '{}'::jsonb; + ELSE + params = TG_ARGV[3]::jsonb; + END IF; + + IF TG_ARGV[4] IS NULL OR TG_ARGV[4] = 'null' THEN + timeout_ms = 1000; + ELSE + timeout_ms = TG_ARGV[4]::integer; + END IF; + + CASE + WHEN method = 'GET' THEN + SELECT http_get INTO request_id FROM net.http_get( + url, + params, + headers, + timeout_ms + ); + WHEN method = 'POST' THEN + payload = jsonb_build_object( + 'old_record', OLD, + 'record', NEW, + 'type', TG_OP, + 'table', TG_TABLE_NAME, + 'schema', TG_TABLE_SCHEMA + ); + + SELECT http_post INTO request_id FROM net.http_post( + url, + payload, + params, + headers, + timeout_ms + ); + ELSE + RAISE EXCEPTION 'method argument % is invalid', method; + END CASE; + + INSERT INTO supabase_functions.hooks + (hook_table_id, hook_name, request_id) + VALUES + (TG_RELID, TG_NAME, request_id); + + RETURN NEW; + END + $function$; + -- Supabase super admin + DO + $$ + BEGIN + IF NOT EXISTS ( + SELECT 1 + FROM pg_roles + WHERE rolname = 'supabase_functions_admin' + ) + THEN + CREATE USER supabase_functions_admin NOINHERIT CREATEROLE LOGIN NOREPLICATION; + END IF; + END + $$; + GRANT ALL PRIVILEGES ON SCHEMA supabase_functions TO supabase_functions_admin; + GRANT ALL PRIVILEGES ON ALL TABLES IN SCHEMA supabase_functions TO supabase_functions_admin; + GRANT ALL PRIVILEGES ON ALL SEQUENCES IN SCHEMA supabase_functions TO supabase_functions_admin; + ALTER USER supabase_functions_admin SET search_path = "supabase_functions"; + ALTER table "supabase_functions".migrations OWNER TO supabase_functions_admin; + ALTER table "supabase_functions".hooks OWNER TO supabase_functions_admin; + ALTER function "supabase_functions".http_request() OWNER TO supabase_functions_admin; + GRANT supabase_functions_admin TO postgres; + -- Remove unused supabase_pg_net_admin role + DO + $$ + BEGIN + IF EXISTS ( + SELECT 1 + FROM pg_roles + WHERE rolname = 'supabase_pg_net_admin' + ) + THEN + REASSIGN OWNED BY supabase_pg_net_admin TO supabase_admin; + DROP OWNED BY supabase_pg_net_admin; + DROP ROLE supabase_pg_net_admin; + END IF; + END + $$; + -- pg_net grants when extension is already enabled + DO + $$ + BEGIN + IF EXISTS ( + SELECT 1 + FROM pg_extension + WHERE extname = 'pg_net' + ) + THEN + GRANT USAGE ON SCHEMA net TO supabase_functions_admin, postgres, anon, authenticated, service_role; + ALTER function net.http_get(url text, params jsonb, headers jsonb, timeout_milliseconds integer) SECURITY DEFINER; + ALTER function net.http_post(url text, body jsonb, params jsonb, headers jsonb, timeout_milliseconds integer) SECURITY DEFINER; + ALTER function net.http_get(url text, params jsonb, headers jsonb, timeout_milliseconds integer) SET search_path = net; + ALTER function net.http_post(url text, body jsonb, params jsonb, headers jsonb, timeout_milliseconds integer) SET search_path = net; + REVOKE ALL ON FUNCTION net.http_get(url text, params jsonb, headers jsonb, timeout_milliseconds integer) FROM PUBLIC; + REVOKE ALL ON FUNCTION net.http_post(url text, body jsonb, params jsonb, headers jsonb, timeout_milliseconds integer) FROM PUBLIC; + GRANT EXECUTE ON FUNCTION net.http_get(url text, params jsonb, headers jsonb, timeout_milliseconds integer) TO supabase_functions_admin, postgres, anon, authenticated, service_role; + GRANT EXECUTE ON FUNCTION net.http_post(url text, body jsonb, params jsonb, headers jsonb, timeout_milliseconds integer) TO supabase_functions_admin, postgres, anon, authenticated, service_role; + END IF; + END + $$; + -- Event trigger for pg_net + CREATE OR REPLACE FUNCTION extensions.grant_pg_net_access() + RETURNS event_trigger + LANGUAGE plpgsql + AS $$ + BEGIN + IF EXISTS ( + SELECT 1 + FROM pg_event_trigger_ddl_commands() AS ev + JOIN pg_extension AS ext + ON ev.objid = ext.oid + WHERE ext.extname = 'pg_net' + ) + THEN + GRANT USAGE ON SCHEMA net TO supabase_functions_admin, postgres, anon, authenticated, service_role; + ALTER function net.http_get(url text, params jsonb, headers jsonb, timeout_milliseconds integer) SECURITY DEFINER; + ALTER function net.http_post(url text, body jsonb, params jsonb, headers jsonb, timeout_milliseconds integer) SECURITY DEFINER; + ALTER function net.http_get(url text, params jsonb, headers jsonb, timeout_milliseconds integer) SET search_path = net; + ALTER function net.http_post(url text, body jsonb, params jsonb, headers jsonb, timeout_milliseconds integer) SET search_path = net; + REVOKE ALL ON FUNCTION net.http_get(url text, params jsonb, headers jsonb, timeout_milliseconds integer) FROM PUBLIC; + REVOKE ALL ON FUNCTION net.http_post(url text, body jsonb, params jsonb, headers jsonb, timeout_milliseconds integer) FROM PUBLIC; + GRANT EXECUTE ON FUNCTION net.http_get(url text, params jsonb, headers jsonb, timeout_milliseconds integer) TO supabase_functions_admin, postgres, anon, authenticated, service_role; + GRANT EXECUTE ON FUNCTION net.http_post(url text, body jsonb, params jsonb, headers jsonb, timeout_milliseconds integer) TO supabase_functions_admin, postgres, anon, authenticated, service_role; + END IF; + END; + $$; + COMMENT ON FUNCTION extensions.grant_pg_net_access IS 'Grants access to pg_net'; + DO + $$ + BEGIN + IF NOT EXISTS ( + SELECT 1 + FROM pg_event_trigger + WHERE evtname = 'issue_pg_net_access' + ) THEN + CREATE EVENT TRIGGER issue_pg_net_access ON ddl_command_end WHEN TAG IN ('CREATE EXTENSION') + EXECUTE PROCEDURE extensions.grant_pg_net_access(); + END IF; + END + $$; + INSERT INTO supabase_functions.migrations (version) VALUES ('20210809183423_update_grants'); + ALTER function supabase_functions.http_request() SECURITY DEFINER; + ALTER function supabase_functions.http_request() SET search_path = supabase_functions; + REVOKE ALL ON FUNCTION supabase_functions.http_request() FROM PUBLIC; + GRANT EXECUTE ON FUNCTION supabase_functions.http_request() TO postgres, anon, authenticated, service_role; +COMMIT; diff --git a/infra/scripts/app.sh b/infra/scripts/app.sh index 852dfa6..a7ce7de 100755 --- a/infra/scripts/app.sh +++ b/infra/scripts/app.sh @@ -1,7 +1,7 @@ #!/bin/bash set -euo pipefail -ROOT_DIR="$(cd "$(dirname "$0")/.." && pwd)" +ROOT_DIR="$(cd "$(dirname "$0")/../.." && pwd)" SESSION_NAME="${SESSION_NAME:-eryao-dev}" ENV_FILE="$ROOT_DIR/.env" @@ -154,9 +154,14 @@ start() { WEB_CMD="cd '$ROOT_DIR' && PYTHONPATH=backend/src ERYAO_RUNTIME__SERVICE_NAME=web uv run uvicorn backend.src.app:app --host ${ERYAO_WEB__HOST:-0.0.0.0} --port ${WEB_PORT} --workers ${ERYAO_WEB__WORKERS:-2} --log-level ${UVICORN_LOG_LEVEL}" + WORKER_AGENT_CMD="cd '$ROOT_DIR' && PYTHONPATH=backend/src ERYAO_RUNTIME__SERVICE_NAME=worker-agent uv run taskiq worker core.taskiq.app:worker_agent_broker core.runtime.tasks --workers ${ERYAO_WORKER__GROUPS__AGENT__CONCURRENCY:-2}" + WORKER_GENERAL_CMD="cd '$ROOT_DIR' && PYTHONPATH=backend/src ERYAO_RUNTIME__SERVICE_NAME=worker-general uv run taskiq worker core.taskiq.app:worker_general_broker core.runtime.tasks --workers ${ERYAO_WORKER__GROUPS__GENERAL__CONCURRENCY:-1}" + echo "Starting tmux web process in session '$SESSION_NAME'..." tmux new-session -d -s "$SESSION_NAME" -n web "bash -lc \"$WEB_CMD; echo '[web] exited'; exec bash\"" + tmux new-window -t "$SESSION_NAME" -n worker-agent "bash -lc \"$WORKER_AGENT_CMD; echo '[worker-agent] exited'; exec bash\"" + tmux new-window -t "$SESSION_NAME" -n worker-general "bash -lc \"$WORKER_GENERAL_CMD; echo '[worker-general] exited'; exec bash\"" echo "" echo "=== App Started ===" diff --git a/pyproject.toml b/pyproject.toml index 9aff33d..56a8abe 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -5,7 +5,7 @@ description = "觅爻签问后端服务" requires-python = ">=3.12" dependencies = [ "alembic==1.18.4", - "aiomysql==0.2.0", + "asyncpg==0.30.0", "cryptography==46.0.3", "email-validator==2.3.0", "fastapi==0.135.1", @@ -16,12 +16,16 @@ dependencies = [ "redis==7.2.1", "sqlalchemy[asyncio]==2.0.48", "structlog==25.5.0", + "supabase==2.21.0", + "storage3==0.8.0", + "taskiq==0.12.1", + "taskiq-redis==1.2.2", "uvicorn[standard]==0.41.0", ] [project.optional-dependencies] dev = [ - "httpx==0.28.1", + "httpx==0.27.2", "pytest==9.0.2", "pytest-asyncio==1.3.0", "pytest-cov==7.0.0",