docs: 更新 HTTP 错误码、用户积分、占卜运行及用户资料协议文档
This commit is contained in:
@@ -0,0 +1,221 @@
|
||||
# Database Guidelines
|
||||
|
||||
> Database patterns and conventions for this project.
|
||||
|
||||
---
|
||||
|
||||
## Overview
|
||||
|
||||
This project uses:
|
||||
- **SQLAlchemy 2.0** with async support (`asyncpg` driver)
|
||||
- **Alembic** for schema migrations
|
||||
- **Supabase Auth** as identity source
|
||||
- **PostgreSQL** as primary database
|
||||
- **Soft delete** pattern with `deleted_at` column
|
||||
|
||||
---
|
||||
|
||||
## Query Patterns
|
||||
|
||||
### Base Repository Pattern
|
||||
|
||||
All repositories inherit from `BaseRepository` (`core.db.base_repository.BaseRepository`):
|
||||
|
||||
```python
|
||||
from core.db.base_repository import BaseRepository
|
||||
from models.agent_chat_session import AgentChatSession
|
||||
|
||||
class AgentRepository(BaseRepository[AgentChatSession]):
|
||||
def __init__(self, session: AsyncSession) -> None:
|
||||
super().__init__(session, AgentChatSession)
|
||||
```
|
||||
|
||||
**Provided methods:**
|
||||
- `get_by_id(entity_id)` - Get single entity by ID
|
||||
- `get_one(*filters)` - Get single entity with filters
|
||||
- `update_by_id(entity_id, update_data)` - Update entity
|
||||
- `soft_delete_by_id(entity_id)` - Soft delete (sets `deleted_at`)
|
||||
|
||||
**Soft delete behavior:**
|
||||
- Queries **automatically exclude** deleted records
|
||||
- `deleted_at` is set to `datetime.now(timezone.utc)`
|
||||
- Repository checks if model has `deleted_at` column before applying filter
|
||||
|
||||
### Query Examples
|
||||
|
||||
```python
|
||||
# Get by ID (auto-filters deleted)
|
||||
session = await repository.get_by_id(session_id)
|
||||
|
||||
# Get with custom filters
|
||||
session = await repository.get_one(
|
||||
AgentChatSession.owner_id == user_id,
|
||||
AgentChatSession.id == session_id,
|
||||
)
|
||||
|
||||
# Update entity
|
||||
updated = await repository.update_by_id(
|
||||
session_id,
|
||||
{"title": "New Title", "updated_at": datetime.now(timezone.utc)},
|
||||
)
|
||||
```
|
||||
|
||||
### Custom Queries
|
||||
|
||||
For complex queries, add custom methods to repository:
|
||||
|
||||
```python
|
||||
class AgentRepository(BaseRepository[AgentChatSession]):
|
||||
async def get_active_sessions_by_user(
|
||||
self, user_id: UUID
|
||||
) -> list[AgentChatSession]:
|
||||
stmt = (
|
||||
select(self._model)
|
||||
.where(self._model.owner_id == user_id)
|
||||
.order_by(self._model.created_at.desc())
|
||||
)
|
||||
stmt = self._apply_soft_delete_filter(stmt)
|
||||
result = await self._session.execute(stmt)
|
||||
return list(result.scalars().all())
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Migrations
|
||||
|
||||
### Creating Migrations
|
||||
|
||||
**Use `dev-migrate.sh` script:**
|
||||
|
||||
```bash
|
||||
# Create new migration
|
||||
./infra/scripts/dev-migrate.sh revision --autogenerate -m "add user preferences table"
|
||||
```
|
||||
|
||||
**Migration file naming:**
|
||||
- Format: `YYYYMMDD_####_<description>.py`
|
||||
- Example: `20260411_0001_initial_llm_schema.py`
|
||||
|
||||
**Migration structure:**
|
||||
|
||||
```python
|
||||
def upgrade() -> None:
|
||||
op.create_table(
|
||||
"llm_factory",
|
||||
sa.Column("id", sa.UUID(), nullable=False),
|
||||
sa.Column("name", sa.String(length=50), nullable=False),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("now()")),
|
||||
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.text("now()")),
|
||||
sa.Column("deleted_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
sa.UniqueConstraint("name"),
|
||||
)
|
||||
op.create_index("ix_llm_factory_name", "llm_factory", ["name"], unique=True)
|
||||
_enable_rls("llm_factory") # RLS for Supabase Auth
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_index("ix_llm_factory_name", table_name="llm_factory")
|
||||
op.drop_table("llm_factory")
|
||||
```
|
||||
|
||||
### Running Migrations
|
||||
|
||||
**Three modes (via `dev-migrate.sh`):**
|
||||
|
||||
```bash
|
||||
# Migrations only
|
||||
./infra/scripts/dev-migrate.sh migrate
|
||||
|
||||
# Seed data only
|
||||
./infra/scripts/dev-migrate.sh init-data
|
||||
|
||||
# Both (migrate + seed)
|
||||
./infra/scripts/dev-migrate.sh bootstrap
|
||||
```
|
||||
|
||||
**Alembic is the ONLY source of truth for schema changes.**
|
||||
|
||||
---
|
||||
|
||||
## Naming Conventions
|
||||
|
||||
### Tables
|
||||
- **snake_case**: `agent_chat_session`, `llm_factory`, `points_ledger`
|
||||
- **Plural or singular**: Based on domain semantics
|
||||
- **Timestamps**: `created_at`, `updated_at`, `deleted_at` (always timezone-aware)
|
||||
|
||||
### Columns
|
||||
- **snake_case**: `owner_id`, `model_code`, `request_url`
|
||||
- **Foreign keys**: `<entity>_id` (e.g., `factory_id`, `owner_id`)
|
||||
- **Indexes**: `ix_<table>_<column>` (e.g., `ix_llms_model_code`)
|
||||
|
||||
### Models
|
||||
- **PascalCase**: `AgentChatSession`, `LlmFactory`
|
||||
- **File location**: `models/<entity>.py`
|
||||
|
||||
---
|
||||
|
||||
## Transactions
|
||||
|
||||
### Service Layer Responsibility
|
||||
|
||||
**Services own transaction boundaries:**
|
||||
|
||||
```python
|
||||
class AgentService:
|
||||
async def enqueue_run(self, ...) -> TaskAccepted:
|
||||
async with self._repository._session.begin(): # Transaction start
|
||||
# Multiple repository calls
|
||||
session = await self._repository.get_by_id(session_id)
|
||||
await self._repository.update_by_id(session_id, {...})
|
||||
# Transaction commit on successful exit
|
||||
```
|
||||
|
||||
### Repository Layer
|
||||
|
||||
**Repositories do NOT manage transactions:**
|
||||
|
||||
```python
|
||||
# Repository uses session, but doesn't commit
|
||||
result = await self._session.execute(stmt)
|
||||
await self._session.flush() # Flush to DB, but don't commit
|
||||
return result.scalar_one_or_none()
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Common Mistakes
|
||||
|
||||
### ❌ Using raw SQL without Alembic
|
||||
- **Wrong**: Creating tables directly in code
|
||||
- **Right**: All DDL changes via Alembic migrations
|
||||
|
||||
### ❌ Hardcoding database credentials
|
||||
- **Wrong**: `password = "postgres123"`
|
||||
- **Right**: Use `core.config.settings.Settings.database.password`
|
||||
|
||||
### ❌ Bypassing soft delete filter
|
||||
- **Wrong**: `select(Model).where(Model.id == x)` (includes deleted)
|
||||
- **Right**: Use `BaseRepository` methods or apply `self._apply_soft_delete_filter()`
|
||||
|
||||
### ❌ Using `session.commit()` in repository
|
||||
- **Wrong**: Repository calling `await session.commit()`
|
||||
- **Right**: Let service layer manage transactions with `session.begin()`
|
||||
|
||||
### ❌ Not using timezone-aware timestamps
|
||||
- **Wrong**: `datetime.now()`
|
||||
- **Right**: `datetime.now(timezone.utc)`
|
||||
|
||||
### ❌ Getting `owner_id` from client payload
|
||||
- **Wrong**: `owner_id = payload.owner_id`
|
||||
- **Right**: `owner_id = current_user.id` (from verified JWT sub claim)
|
||||
|
||||
---
|
||||
|
||||
## Database Access Rules
|
||||
|
||||
**From AGENTS.md:**
|
||||
1. Use `supabase mcp` tools (`supabase_execute_sql`, `supabase_list_tables`) to **view** data
|
||||
2. Use **Alembic migrations** to **modify** schema
|
||||
3. Use **service-role DB access only** in backend
|
||||
4. Soft delete uses `deleted_at`; reads exclude deleted by default
|
||||
Reference in New Issue
Block a user