feat(m0): 后端骨架 + Web/安卓前端骨架 + 设计文档
- backend: FastAPI 应用工厂、统一错误信封、JWT、短信登录、工作区/成员/邀请、幂等中间件 - alembic 0001: workspace/member/invitation/audit_log/app_user + RLS 策略 + 双角色 GRANT - tests: 认证/工作区/租户隔离/幂等 四组用例(尚未在本机执行) - web: React18+Vite 骨架(登录、工作区、邀请落地页) - android: Compose 骨架(登录、工作区列表) - 2.Design: PRODUCT-PLAN / ROADMAP-M0-M1 / NAMING / DEVELOPMENT
This commit is contained in:
@@ -0,0 +1,18 @@
|
||||
# 复制为 .env 后按需修改;.env 不进仓库
|
||||
ENV=dev
|
||||
APP_NAME=达道Task
|
||||
|
||||
# 应用角色(受 RLS 约束)—— 应用进程只能用它
|
||||
DATABASE_URL=postgresql+asyncpg://dadao_app:dadao_dev_pw@127.0.0.1:5433/dadaotask
|
||||
# 迁移角色(owner,绕过 RLS)—— 只有 alembic 用
|
||||
MIGRATION_DATABASE_URL=postgresql+asyncpg://dadao_owner:dadao_dev_pw@127.0.0.1:5433/dadaotask
|
||||
REDIS_URL=redis://127.0.0.1:6380/0
|
||||
|
||||
JWT_SECRET=dev-only-change-me
|
||||
ACCESS_TOKEN_MINUTES=30
|
||||
REFRESH_TOKEN_DAYS=14
|
||||
|
||||
SMS_PROVIDER=dev
|
||||
SMS_DEV_CODE=123456
|
||||
|
||||
CORS_ORIGINS=http://localhost:5180,http://127.0.0.1:5180
|
||||
@@ -0,0 +1,38 @@
|
||||
[alembic]
|
||||
script_location = alembic
|
||||
prepend_sys_path = .
|
||||
version_path_separator = os
|
||||
|
||||
[loggers]
|
||||
keys = root,sqlalchemy,alembic
|
||||
|
||||
[handlers]
|
||||
keys = console
|
||||
|
||||
[formatters]
|
||||
keys = generic
|
||||
|
||||
[logger_root]
|
||||
level = WARN
|
||||
handlers = console
|
||||
qualname =
|
||||
|
||||
[logger_sqlalchemy]
|
||||
level = WARN
|
||||
handlers =
|
||||
qualname = sqlalchemy.engine
|
||||
|
||||
[logger_alembic]
|
||||
level = INFO
|
||||
handlers =
|
||||
qualname = alembic
|
||||
|
||||
[handler_console]
|
||||
class = StreamHandler
|
||||
args = (sys.stderr,)
|
||||
level = NOTSET
|
||||
formatter = generic
|
||||
|
||||
[formatter_generic]
|
||||
format = %(levelname)-5.5s [%(name)s] %(message)s
|
||||
datefmt = %H:%M:%S
|
||||
@@ -0,0 +1,66 @@
|
||||
"""Alembic 环境:用**迁移角色**(owner)连接,与应用的受限角色分开。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from logging.config import fileConfig
|
||||
|
||||
from alembic import context
|
||||
from sqlalchemy import pool
|
||||
from sqlalchemy.engine import Connection
|
||||
from sqlalchemy.ext.asyncio import async_engine_from_config
|
||||
|
||||
from app.config import get_settings
|
||||
from app.models import Base
|
||||
|
||||
config = context.config
|
||||
if config.config_file_name is not None:
|
||||
fileConfig(config.config_file_name)
|
||||
|
||||
config.set_main_option("sqlalchemy.url", get_settings().migration_database_url)
|
||||
target_metadata = Base.metadata
|
||||
|
||||
|
||||
def run_migrations_offline() -> None:
|
||||
context.configure(
|
||||
url=config.get_main_option("sqlalchemy.url"),
|
||||
target_metadata=target_metadata,
|
||||
literal_binds=True,
|
||||
dialect_opts={"paramstyle": "named"},
|
||||
compare_type=True,
|
||||
)
|
||||
with context.begin_transaction():
|
||||
context.run_migrations()
|
||||
|
||||
|
||||
def do_run_migrations(connection: Connection) -> None:
|
||||
context.configure(
|
||||
connection=connection,
|
||||
target_metadata=target_metadata,
|
||||
compare_type=True,
|
||||
# RLS 策略、GRANT 这类"非表结构"变更 autogenerate 看不见,必须手写
|
||||
include_schemas=False,
|
||||
)
|
||||
with context.begin_transaction():
|
||||
context.run_migrations()
|
||||
|
||||
|
||||
async def run_async_migrations() -> None:
|
||||
connectable = async_engine_from_config(
|
||||
config.get_section(config.config_ini_section, {}),
|
||||
prefix="sqlalchemy.",
|
||||
poolclass=pool.NullPool,
|
||||
)
|
||||
async with connectable.connect() as connection:
|
||||
await connection.run_sync(do_run_migrations)
|
||||
await connectable.dispose()
|
||||
|
||||
|
||||
def run_migrations_online() -> None:
|
||||
asyncio.run(run_async_migrations())
|
||||
|
||||
|
||||
if context.is_offline_mode():
|
||||
run_migrations_offline()
|
||||
else:
|
||||
run_migrations_online()
|
||||
@@ -0,0 +1,27 @@
|
||||
"""${message}
|
||||
|
||||
Revision ID: ${up_revision}
|
||||
Revises: ${down_revision | comma,n}
|
||||
Create Date: ${create_date}
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Sequence
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
${imports if imports else ""}
|
||||
|
||||
revision: str = ${repr(up_revision)}
|
||||
down_revision: str | None = ${repr(down_revision)}
|
||||
branch_labels: str | Sequence[str] | None = ${repr(branch_labels)}
|
||||
depends_on: str | Sequence[str] | None = ${repr(depends_on)}
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
${upgrades if upgrades else "pass"}
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
${downgrades if downgrades else "pass"}
|
||||
@@ -0,0 +1,197 @@
|
||||
"""M0 初始结构:workspace / app_user / member / invitation / audit_log + 行级安全。
|
||||
|
||||
注意:RLS 策略是**安全边界**,不是优化项。改这个文件请同时改
|
||||
`tests/test_tenant_isolation.py`,并在 PR 里说明为什么。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Sequence
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
from sqlalchemy.dialects import postgresql
|
||||
|
||||
revision: str = "0001"
|
||||
down_revision: str | None = None
|
||||
branch_labels: str | Sequence[str] | None = None
|
||||
depends_on: str | Sequence[str] | None = None
|
||||
|
||||
APP_ROLE = "dadao_app"
|
||||
|
||||
HELPER_SQL = """
|
||||
CREATE OR REPLACE FUNCTION app_ws_id() RETURNS uuid
|
||||
LANGUAGE sql STABLE AS $$
|
||||
SELECT nullif(current_setting('app.ws_id', true), '')::uuid
|
||||
$$;
|
||||
|
||||
CREATE OR REPLACE FUNCTION app_user_id() RETURNS uuid
|
||||
LANGUAGE sql STABLE AS $$
|
||||
SELECT nullif(current_setting('app.user_id', true), '')::uuid
|
||||
$$;
|
||||
"""
|
||||
|
||||
POLICY_SQL = f"""
|
||||
-- ── workspace:本租户 OR 我是其成员(用于"我加入了哪些工作区")──
|
||||
ALTER TABLE workspace ENABLE ROW LEVEL SECURITY;
|
||||
CREATE POLICY workspace_isolation ON workspace
|
||||
USING (
|
||||
id = app_ws_id()
|
||||
OR id IN (SELECT m.workspace_id FROM member m WHERE m.user_id = app_user_id())
|
||||
)
|
||||
WITH CHECK (id = app_ws_id());
|
||||
|
||||
-- ── member:本租户成员 OR 我自己那条 ──
|
||||
ALTER TABLE member ENABLE ROW LEVEL SECURITY;
|
||||
CREATE POLICY member_isolation ON member
|
||||
USING (workspace_id = app_ws_id() OR user_id = app_user_id())
|
||||
WITH CHECK (workspace_id = app_ws_id());
|
||||
|
||||
-- ── invitation ──
|
||||
ALTER TABLE invitation ENABLE ROW LEVEL SECURITY;
|
||||
CREATE POLICY invitation_isolation ON invitation
|
||||
USING (workspace_id = app_ws_id())
|
||||
WITH CHECK (workspace_id = app_ws_id());
|
||||
|
||||
-- ── audit_log:只增不改,租户内可见 ──
|
||||
ALTER TABLE audit_log ENABLE ROW LEVEL SECURITY;
|
||||
CREATE POLICY audit_log_isolation ON audit_log
|
||||
USING (workspace_id = app_ws_id())
|
||||
WITH CHECK (workspace_id = app_ws_id());
|
||||
|
||||
-- ── 授权:应用角色没有 DDL、没有 BYPASSRLS,只能读写到自己该看的数据 ──
|
||||
GRANT USAGE ON SCHEMA public TO {APP_ROLE};
|
||||
GRANT SELECT, INSERT, UPDATE, DELETE ON
|
||||
workspace, app_user, member, invitation, audit_log TO {APP_ROLE};
|
||||
ALTER DEFAULT PRIVILEGES FOR ROLE dadao_owner IN SCHEMA public
|
||||
GRANT SELECT, INSERT, UPDATE, DELETE ON TABLES TO {APP_ROLE};
|
||||
"""
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.execute(HELPER_SQL)
|
||||
|
||||
op.create_table(
|
||||
"app_user",
|
||||
sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True),
|
||||
sa.Column("phone", sa.String(32), nullable=False, unique=True),
|
||||
sa.Column("nickname", sa.String(64), nullable=False, server_default=""),
|
||||
sa.Column("avatar_url", sa.String(512)),
|
||||
sa.Column("status", sa.String(16), nullable=False, server_default="active"),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()),
|
||||
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()),
|
||||
sa.Column("version", sa.Integer, nullable=False, server_default="1"),
|
||||
)
|
||||
op.create_index("ix_app_user_phone", "app_user", ["phone"])
|
||||
|
||||
op.create_table(
|
||||
"workspace",
|
||||
sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True),
|
||||
sa.Column("name", sa.String(64), nullable=False),
|
||||
sa.Column("slug", sa.String(64), nullable=False, unique=True),
|
||||
sa.Column("plan", sa.String(16), nullable=False, server_default="free"),
|
||||
sa.Column(
|
||||
"owner_id",
|
||||
postgresql.UUID(as_uuid=True),
|
||||
sa.ForeignKey("app_user.id", ondelete="RESTRICT"),
|
||||
nullable=False,
|
||||
),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()),
|
||||
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()),
|
||||
sa.Column("version", sa.Integer, nullable=False, server_default="1"),
|
||||
sa.Column("deleted_at", sa.DateTime(timezone=True)),
|
||||
)
|
||||
op.create_index("ix_workspace_owner", "workspace", ["owner_id"])
|
||||
|
||||
op.create_table(
|
||||
"member",
|
||||
sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True),
|
||||
sa.Column(
|
||||
"workspace_id",
|
||||
postgresql.UUID(as_uuid=True),
|
||||
sa.ForeignKey("workspace.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
),
|
||||
sa.Column(
|
||||
"user_id",
|
||||
postgresql.UUID(as_uuid=True),
|
||||
sa.ForeignKey("app_user.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
),
|
||||
sa.Column("role", sa.String(16), nullable=False, server_default="member"),
|
||||
sa.Column("status", sa.String(16), nullable=False, server_default="active"),
|
||||
sa.Column("joined_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()),
|
||||
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()),
|
||||
sa.Column("version", sa.Integer, nullable=False, server_default="1"),
|
||||
sa.UniqueConstraint("workspace_id", "user_id", name="uq_member_ws_user"),
|
||||
sa.CheckConstraint("role IN ('owner','admin','member','guest')", name="ck_member_role"),
|
||||
sa.CheckConstraint("status IN ('active','removed')", name="ck_member_status"),
|
||||
)
|
||||
op.create_index("ix_member_user", "member", ["user_id"])
|
||||
op.create_index("ix_member_created", "member", ["created_at", "id"])
|
||||
|
||||
op.create_table(
|
||||
"invitation",
|
||||
sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True),
|
||||
sa.Column(
|
||||
"workspace_id",
|
||||
postgresql.UUID(as_uuid=True),
|
||||
sa.ForeignKey("workspace.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
),
|
||||
sa.Column("code", sa.String(32), nullable=False),
|
||||
sa.Column("role", sa.String(16), nullable=False, server_default="member"),
|
||||
sa.Column(
|
||||
"invited_by",
|
||||
postgresql.UUID(as_uuid=True),
|
||||
sa.ForeignKey("app_user.id", ondelete="RESTRICT"),
|
||||
nullable=False,
|
||||
),
|
||||
sa.Column("expires_at", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.Column("accepted_at", sa.DateTime(timezone=True)),
|
||||
sa.Column(
|
||||
"accepted_by",
|
||||
postgresql.UUID(as_uuid=True),
|
||||
sa.ForeignKey("app_user.id", ondelete="SET NULL"),
|
||||
),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()),
|
||||
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()),
|
||||
sa.Column("version", sa.Integer, nullable=False, server_default="1"),
|
||||
sa.UniqueConstraint("code", name="uq_invitation_code"),
|
||||
)
|
||||
op.create_index("ix_invitation_ws", "invitation", ["workspace_id"])
|
||||
|
||||
op.create_table(
|
||||
"audit_log",
|
||||
sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True),
|
||||
sa.Column(
|
||||
"workspace_id",
|
||||
postgresql.UUID(as_uuid=True),
|
||||
sa.ForeignKey("workspace.id", ondelete="SET NULL"),
|
||||
),
|
||||
sa.Column(
|
||||
"actor_user_id",
|
||||
postgresql.UUID(as_uuid=True),
|
||||
sa.ForeignKey("app_user.id", ondelete="SET NULL"),
|
||||
),
|
||||
sa.Column("action", sa.String(64), nullable=False),
|
||||
sa.Column("target_type", sa.String(32), nullable=False, server_default=""),
|
||||
sa.Column("target_id", sa.String(64), nullable=False, server_default=""),
|
||||
sa.Column("payload", postgresql.JSONB),
|
||||
sa.Column("ip", sa.String(64), nullable=False, server_default=""),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()),
|
||||
)
|
||||
op.create_index("ix_audit_ws_created", "audit_log", ["workspace_id", "created_at"])
|
||||
|
||||
op.execute(POLICY_SQL)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.execute(f"REVOKE ALL ON workspace, app_user, member, invitation, audit_log FROM {APP_ROLE};")
|
||||
op.drop_table("audit_log")
|
||||
op.drop_table("invitation")
|
||||
op.drop_table("member")
|
||||
op.drop_table("workspace")
|
||||
op.drop_table("app_user")
|
||||
op.execute("DROP FUNCTION IF EXISTS app_ws_id(); DROP FUNCTION IF EXISTS app_user_id();")
|
||||
@@ -0,0 +1,142 @@
|
||||
"""认证:手机号 + 验证码。M0 不做密码、不做第三方登录。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hmac
|
||||
import secrets
|
||||
|
||||
from fastapi import APIRouter
|
||||
from pydantic import BaseModel, Field
|
||||
from sqlalchemy import select
|
||||
|
||||
from ..config import get_settings
|
||||
from ..deps import CurrentUser, SessionDep
|
||||
from ..errors import AppError, ErrorCode
|
||||
from ..models import AppUser, Member, Workspace
|
||||
from ..schemas import Envelope, ok
|
||||
from ..security import create_token_pair, decode_token
|
||||
from ..stores import get_store, sms_code_key, sms_throttle_key
|
||||
|
||||
router = APIRouter(prefix="/auth", tags=["认证"])
|
||||
settings = get_settings()
|
||||
|
||||
PHONE_PATTERN = r"^1[3-9]\d{9}$"
|
||||
|
||||
|
||||
def mask_phone(phone: str) -> str:
|
||||
return f"{phone[:3]}****{phone[-4:]}" if len(phone) == 11 else "***"
|
||||
|
||||
|
||||
class SendCodeIn(BaseModel):
|
||||
phone: str = Field(pattern=PHONE_PATTERN, examples=["13800138000"])
|
||||
|
||||
|
||||
class LoginIn(BaseModel):
|
||||
phone: str = Field(pattern=PHONE_PATTERN)
|
||||
code: str = Field(min_length=4, max_length=8)
|
||||
|
||||
|
||||
class RefreshIn(BaseModel):
|
||||
refresh_token: str = Field(min_length=10)
|
||||
|
||||
|
||||
@router.post("/sms/send", summary="发送登录验证码")
|
||||
async def send_code(body: SendCodeIn) -> dict:
|
||||
store = get_store()
|
||||
hits = await store.incr(sms_throttle_key(body.phone), 60)
|
||||
if hits > 1:
|
||||
raise AppError(ErrorCode.SMS_TOO_FREQUENT, "发送过于频繁,请 60 秒后重试")
|
||||
|
||||
if settings.sms_provider == "dev":
|
||||
code = settings.sms_dev_code
|
||||
else:
|
||||
code = f"{secrets.randbelow(1_000_000):06d}"
|
||||
# TODO(M0 遗留):接入阿里云/腾讯云短信;当前直接在响应里不回显验证码
|
||||
raise AppError(ErrorCode.INTERNAL, "短信通道尚未接入", http_status=501)
|
||||
|
||||
await store.set(sms_code_key(body.phone), code, settings.sms_code_ttl_seconds)
|
||||
return ok({"sent": True, "expires_in": settings.sms_code_ttl_seconds, "dev_code": code})
|
||||
|
||||
|
||||
@router.post("/login", summary="验证码登录(不存在则自动注册)")
|
||||
async def login(body: LoginIn, session: SessionDep) -> dict:
|
||||
store = get_store()
|
||||
stored = await store.get(sms_code_key(body.phone))
|
||||
if stored is None:
|
||||
raise AppError(ErrorCode.SMS_CODE_INVALID, "验证码已过期,请重新获取")
|
||||
if not hmac.compare_digest(stored, body.code):
|
||||
raise AppError(ErrorCode.SMS_CODE_INVALID, "验证码错误")
|
||||
await store.delete(sms_code_key(body.phone))
|
||||
|
||||
user = (
|
||||
await session.execute(select(AppUser).where(AppUser.phone == body.phone))
|
||||
).scalar_one_or_none()
|
||||
if user is None:
|
||||
user = AppUser(phone=body.phone, nickname=f"用户{body.phone[-4:]}")
|
||||
session.add(user)
|
||||
await session.flush()
|
||||
|
||||
tokens = create_token_pair(user.id)
|
||||
return ok(
|
||||
{
|
||||
"user": {
|
||||
"id": str(user.id),
|
||||
"phone": mask_phone(user.phone),
|
||||
"nickname": user.nickname,
|
||||
"created_at": user.created_at,
|
||||
},
|
||||
"tokens": {
|
||||
"access_token": tokens.access_token,
|
||||
"refresh_token": tokens.refresh_token,
|
||||
"token_type": "bearer",
|
||||
"expires_in": tokens.expires_in,
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@router.post("/refresh", summary="刷新访问令牌")
|
||||
async def refresh(body: RefreshIn) -> dict:
|
||||
user_id = decode_token(body.refresh_token, "refresh")
|
||||
tokens = create_token_pair(user_id)
|
||||
return ok(
|
||||
{
|
||||
"access_token": tokens.access_token,
|
||||
"refresh_token": tokens.refresh_token,
|
||||
"token_type": "bearer",
|
||||
"expires_in": tokens.expires_in,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@router.get("/me", summary="当前用户与所属工作区")
|
||||
async def me(user: CurrentUser, session: SessionDep) -> dict:
|
||||
rows = (
|
||||
await session.execute(
|
||||
select(Workspace, Member.role)
|
||||
.join(Member, Member.workspace_id == Workspace.id)
|
||||
.where(Member.user_id == user.id, Member.status == "active")
|
||||
.order_by(Workspace.created_at.desc())
|
||||
)
|
||||
).all()
|
||||
|
||||
return ok(
|
||||
{
|
||||
"user": {
|
||||
"id": str(user.id),
|
||||
"phone": mask_phone(user.phone),
|
||||
"nickname": user.nickname,
|
||||
},
|
||||
"workspaces": [
|
||||
{
|
||||
"id": str(ws.id),
|
||||
"name": ws.name,
|
||||
"slug": ws.slug,
|
||||
"plan": ws.plan,
|
||||
"role": role,
|
||||
"created_at": ws.created_at,
|
||||
}
|
||||
for ws, role in rows
|
||||
],
|
||||
}
|
||||
)
|
||||
@@ -0,0 +1,67 @@
|
||||
"""对外输出模型。字段命名一次定死,客户端按契约生成。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
|
||||
|
||||
class UserOut(BaseModel):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
id: uuid.UUID
|
||||
phone: str = Field(description="已打码的手机号,如 138****8000")
|
||||
nickname: str
|
||||
created_at: datetime
|
||||
|
||||
|
||||
class TokenOut(BaseModel):
|
||||
access_token: str
|
||||
refresh_token: str
|
||||
token_type: str = "bearer"
|
||||
expires_in: int
|
||||
|
||||
|
||||
class LoginOut(BaseModel):
|
||||
user: UserOut
|
||||
tokens: TokenOut
|
||||
|
||||
|
||||
class WorkspaceOut(BaseModel):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
id: uuid.UUID
|
||||
name: str
|
||||
slug: str
|
||||
plan: str
|
||||
role: str = ""
|
||||
created_at: datetime
|
||||
|
||||
|
||||
class MemberOut(BaseModel):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
id: uuid.UUID
|
||||
user_id: uuid.UUID
|
||||
role: str
|
||||
status: str
|
||||
nickname: str = ""
|
||||
phone: str = ""
|
||||
joined_at: datetime
|
||||
|
||||
|
||||
class InvitationOut(BaseModel):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
id: uuid.UUID
|
||||
code: str
|
||||
role: str
|
||||
expires_at: datetime
|
||||
accepted_at: datetime | None = None
|
||||
|
||||
|
||||
class MeOut(BaseModel):
|
||||
user: UserOut
|
||||
workspaces: list[WorkspaceOut]
|
||||
@@ -0,0 +1,240 @@
|
||||
"""工作区 / 成员 / 邀请。M0 的验收核心:两个租户互相看不见。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from datetime import UTC, datetime, timedelta
|
||||
|
||||
from fastapi import APIRouter, Query, Request
|
||||
from pydantic import BaseModel, Field
|
||||
from sqlalchemy import select, tuple_
|
||||
|
||||
from ..audit import record as audit
|
||||
from ..db import bind_tenant
|
||||
from ..deps import AdminDep, CurrentUser, MemberDep, SessionDep, TenantDep
|
||||
from ..errors import AppError, ErrorCode
|
||||
from ..models import AppUser, Invitation, Member, Workspace
|
||||
from ..schemas import Page, decode_cursor, encode_cursor, ok
|
||||
from ..security import invite_code_matches, new_invite_code
|
||||
|
||||
router = APIRouter(prefix="/workspaces", tags=["工作区"])
|
||||
|
||||
ROLE_CHOICES = ("admin", "member", "guest")
|
||||
|
||||
|
||||
class CreateWorkspaceIn(BaseModel):
|
||||
name: str = Field(min_length=1, max_length=64)
|
||||
slug: str | None = Field(default=None, max_length=64)
|
||||
|
||||
|
||||
class CreateInvitationIn(BaseModel):
|
||||
role: str = Field(default="member")
|
||||
expires_hours: int = Field(default=72, ge=1, le=24 * 30)
|
||||
|
||||
|
||||
class JoinIn(BaseModel):
|
||||
code: str = Field(min_length=4, max_length=32)
|
||||
|
||||
|
||||
def _ws_out(ws: Workspace, role: str) -> dict:
|
||||
return {
|
||||
"id": str(ws.id),
|
||||
"name": ws.name,
|
||||
"slug": ws.slug,
|
||||
"plan": ws.plan,
|
||||
"role": role,
|
||||
"created_at": ws.created_at,
|
||||
}
|
||||
|
||||
|
||||
@router.post("", summary="创建工作区(创建者成为 owner)")
|
||||
async def create_workspace(
|
||||
body: CreateWorkspaceIn, user: CurrentUser, session: SessionDep, request: Request
|
||||
) -> dict:
|
||||
ws_id = uuid.uuid4()
|
||||
slug = body.slug or f"ws-{ws_id.hex[:8]}"
|
||||
|
||||
exists = (
|
||||
await session.execute(select(Workspace.id).where(Workspace.slug == slug))
|
||||
).scalar_one_or_none()
|
||||
if exists is not None:
|
||||
raise AppError(ErrorCode.CONFLICT, "该 slug 已被占用")
|
||||
|
||||
# 先绑租户再插数据:workspace 的 RLS 策略是 id = app.ws_id,
|
||||
# 插入时 Postgres 用 WITH CHECK 校验,不绑就会直接被拒。
|
||||
await bind_tenant(session, ws_id, user.id)
|
||||
|
||||
ws = Workspace(id=ws_id, name=body.name, slug=slug, owner_id=user.id, plan="free")
|
||||
session.add(ws)
|
||||
session.add(
|
||||
Member(workspace_id=ws_id, user_id=user.id, role="owner", status="active")
|
||||
)
|
||||
await session.flush()
|
||||
await audit(
|
||||
session,
|
||||
workspace_id=ws_id,
|
||||
actor_user_id=user.id,
|
||||
action="workspace.create",
|
||||
target_type="workspace",
|
||||
target_id=str(ws_id),
|
||||
payload={"name": body.name},
|
||||
ip=request.client.host if request.client else "",
|
||||
)
|
||||
return ok(_ws_out(ws, "owner"))
|
||||
|
||||
|
||||
@router.get("", summary="我的工作区列表")
|
||||
async def list_workspaces(user: CurrentUser, session: SessionDep) -> dict:
|
||||
rows = (
|
||||
await session.execute(
|
||||
select(Workspace, Member.role)
|
||||
.join(Member, Member.workspace_id == Workspace.id)
|
||||
.where(Member.user_id == user.id, Member.status == "active")
|
||||
.order_by(Workspace.created_at.desc())
|
||||
)
|
||||
).all()
|
||||
return ok({"items": [_ws_out(ws, role) for ws, role in rows]})
|
||||
|
||||
|
||||
@router.get("/{ws_id}", summary="工作区详情")
|
||||
async def get_workspace(ctx: MemberDep, session: SessionDep) -> dict:
|
||||
ws = await session.get(Workspace, ctx.ws_id)
|
||||
if ws is None:
|
||||
raise AppError(ErrorCode.WORKSPACE_NOT_FOUND, "工作区不存在")
|
||||
return ok(_ws_out(ws, ctx.role))
|
||||
|
||||
|
||||
@router.get("/{ws_id}/members", summary="成员列表(游标分页)")
|
||||
async def list_members(
|
||||
ctx: MemberDep,
|
||||
session: SessionDep,
|
||||
limit: int = Query(default=20, ge=1, le=100),
|
||||
cursor: str | None = Query(default=None),
|
||||
) -> dict:
|
||||
stmt = (
|
||||
select(Member, AppUser)
|
||||
.join(AppUser, AppUser.id == Member.user_id)
|
||||
.where(Member.workspace_id == ctx.ws_id)
|
||||
)
|
||||
if cursor:
|
||||
cur = decode_cursor(cursor)
|
||||
stmt = stmt.where(tuple_(Member.created_at, Member.id) < (cur.created_at, cur.id))
|
||||
stmt = stmt.order_by(Member.created_at.desc(), Member.id.desc()).limit(limit + 1)
|
||||
|
||||
rows = (await session.execute(stmt)).all()
|
||||
has_more = len(rows) > limit
|
||||
rows = rows[:limit]
|
||||
items = [
|
||||
{
|
||||
"id": str(m.id),
|
||||
"user_id": str(m.user_id),
|
||||
"role": m.role,
|
||||
"status": m.status,
|
||||
"nickname": u.nickname,
|
||||
"phone": f"{u.phone[:3]}****{u.phone[-4:]}",
|
||||
"joined_at": m.joined_at,
|
||||
}
|
||||
for m, u in rows
|
||||
]
|
||||
next_cursor = (
|
||||
encode_cursor(rows[-1][0].created_at, rows[-1][0].id) if has_more and rows else None
|
||||
)
|
||||
return ok({"items": items, "next_cursor": next_cursor, "has_more": has_more})
|
||||
|
||||
|
||||
@router.post("/{ws_id}/invitations", summary="生成邀请码(admin 及以上)")
|
||||
async def create_invitation(
|
||||
body: CreateInvitationIn, ctx: AdminDep, session: SessionDep, request: Request
|
||||
) -> dict:
|
||||
if body.role not in ROLE_CHOICES:
|
||||
raise AppError(ErrorCode.BAD_REQUEST, f"角色只能是 {', '.join(ROLE_CHOICES)}")
|
||||
if body.role == "admin" and ctx.role != "owner":
|
||||
raise AppError(ErrorCode.INSUFFICIENT_ROLE, "只有 owner 能邀请 admin")
|
||||
|
||||
invitation = Invitation(
|
||||
workspace_id=ctx.ws_id,
|
||||
code=new_invite_code(),
|
||||
role=body.role,
|
||||
invited_by=ctx.user.id,
|
||||
expires_at=datetime.now(UTC) + timedelta(hours=body.expires_hours),
|
||||
)
|
||||
session.add(invitation)
|
||||
await session.flush()
|
||||
await audit(
|
||||
session,
|
||||
workspace_id=ctx.ws_id,
|
||||
actor_user_id=ctx.user.id,
|
||||
action="invitation.create",
|
||||
target_type="invitation",
|
||||
target_id=str(invitation.id),
|
||||
payload={"role": body.role},
|
||||
ip=request.client.host if request.client else "",
|
||||
)
|
||||
return ok(
|
||||
{
|
||||
"id": str(invitation.id),
|
||||
"code": invitation.code,
|
||||
"role": invitation.role,
|
||||
"expires_at": invitation.expires_at,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@router.post("/{ws_id}/join", summary="凭邀请码加入工作区")
|
||||
async def join_workspace(
|
||||
body: JoinIn, ctx: TenantDep, session: SessionDep, request: Request
|
||||
) -> dict:
|
||||
invitation = (
|
||||
await session.execute(
|
||||
select(Invitation).where(
|
||||
Invitation.workspace_id == ctx.ws_id,
|
||||
Invitation.accepted_at.is_(None),
|
||||
)
|
||||
)
|
||||
).scalars()
|
||||
matched = next(
|
||||
(inv for inv in invitation if invite_code_matches(body.code, inv.code)), None
|
||||
)
|
||||
if matched is None:
|
||||
raise AppError(ErrorCode.INVITATION_INVALID, "邀请码无效或已使用")
|
||||
if matched.expires_at < datetime.now(UTC):
|
||||
raise AppError(ErrorCode.INVITATION_INVALID, "邀请码已过期")
|
||||
|
||||
existing = (
|
||||
await session.execute(
|
||||
select(Member).where(
|
||||
Member.workspace_id == ctx.ws_id, Member.user_id == ctx.user.id
|
||||
)
|
||||
)
|
||||
).scalar_one_or_none()
|
||||
if existing is not None and existing.status == "active":
|
||||
raise AppError(ErrorCode.ALREADY_A_MEMBER, "你已经是该工作区成员")
|
||||
|
||||
if existing is None:
|
||||
session.add(
|
||||
Member(
|
||||
workspace_id=ctx.ws_id,
|
||||
user_id=ctx.user.id,
|
||||
role=matched.role,
|
||||
status="active",
|
||||
)
|
||||
)
|
||||
else:
|
||||
existing.role = matched.role
|
||||
existing.status = "active"
|
||||
|
||||
matched.accepted_at = datetime.now(UTC)
|
||||
matched.accepted_by = ctx.user.id
|
||||
await session.flush()
|
||||
await audit(
|
||||
session,
|
||||
workspace_id=ctx.ws_id,
|
||||
actor_user_id=ctx.user.id,
|
||||
action="member.join",
|
||||
target_type="member",
|
||||
target_id=str(ctx.user.id),
|
||||
payload={"role": matched.role},
|
||||
ip=request.client.host if request.client else "",
|
||||
)
|
||||
ws = await session.get(Workspace, ctx.ws_id)
|
||||
return ok(_ws_out(ws, matched.role) if ws else None)
|
||||
@@ -0,0 +1,35 @@
|
||||
"""审计日志写入。所有敏感操作(建工作区、邀请、加入、移除成员)都要留痕。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from .models import AuditLog
|
||||
|
||||
|
||||
async def record(
|
||||
session: AsyncSession,
|
||||
*,
|
||||
workspace_id: uuid.UUID | None,
|
||||
actor_user_id: uuid.UUID | None,
|
||||
action: str,
|
||||
target_type: str = "",
|
||||
target_id: str = "",
|
||||
payload: dict[str, Any] | None = None,
|
||||
ip: str = "",
|
||||
) -> None:
|
||||
session.add(
|
||||
AuditLog(
|
||||
workspace_id=workspace_id,
|
||||
actor_user_id=actor_user_id,
|
||||
action=action,
|
||||
target_type=target_type,
|
||||
target_id=target_id,
|
||||
payload=payload or {},
|
||||
ip=ip,
|
||||
)
|
||||
)
|
||||
await session.flush()
|
||||
@@ -0,0 +1,93 @@
|
||||
"""分层配置:dev / prod 全部走环境变量,启动即校验。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from functools import lru_cache
|
||||
from typing import Literal
|
||||
|
||||
from pydantic import Field, field_validator
|
||||
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||
|
||||
DEV_JWT_SECRET = "dev-only-change-me"
|
||||
|
||||
|
||||
class Settings(BaseSettings):
|
||||
model_config = SettingsConfigDict(
|
||||
env_file=".env", env_file_encoding="utf-8", extra="ignore", case_sensitive=False
|
||||
)
|
||||
|
||||
# ── 基础 ──────────────────────────────────────────────
|
||||
app_name: str = "达道Task"
|
||||
env: Literal["dev", "test", "prod"] = "dev"
|
||||
api_prefix: str = "/api/v1"
|
||||
cors_origins: list[str] = Field(
|
||||
default_factory=lambda: ["http://localhost:5180", "http://127.0.0.1:5180"]
|
||||
)
|
||||
|
||||
# ── 数据库 ────────────────────────────────────────────
|
||||
# 应用角色:受 RLS 约束,非 owner、非 superuser
|
||||
database_url: str = (
|
||||
"postgresql+asyncpg://dadao_app:dadao_dev_pw@127.0.0.1:5433/dadaotask"
|
||||
)
|
||||
# 迁移角色:owner,可绕过 RLS,仅 alembic / 运维脚本使用
|
||||
migration_database_url: str = (
|
||||
"postgresql+asyncpg://dadao_owner:dadao_dev_pw@127.0.0.1:5433/dadaotask"
|
||||
)
|
||||
db_echo: bool = False
|
||||
db_pool_size: int = 10
|
||||
|
||||
# ── Redis ─────────────────────────────────────────────
|
||||
redis_url: str = "redis://127.0.0.1:6380/0"
|
||||
|
||||
# ── 认证 ──────────────────────────────────────────────
|
||||
jwt_secret: str = DEV_JWT_SECRET
|
||||
jwt_algorithm: str = "HS256"
|
||||
access_token_minutes: int = 30
|
||||
refresh_token_days: int = 14
|
||||
sms_code_ttl_seconds: int = 300
|
||||
sms_dev_code: str = "123456"
|
||||
sms_provider: Literal["dev", "aliyun", "tencent"] = "dev"
|
||||
|
||||
# ── 幂等 ──────────────────────────────────────────────
|
||||
idempotency_enabled: bool = True
|
||||
idempotency_ttl_seconds: int = 86_400
|
||||
|
||||
# ── 构建标记(部署校验用)──────────────────────────────
|
||||
build_commit: str = "unknown"
|
||||
build_time: str = "unknown"
|
||||
|
||||
@field_validator("cors_origins", mode="before")
|
||||
@classmethod
|
||||
def _split_origins(cls, value: object) -> object:
|
||||
"""支持 CORS_ORIGINS="a,b" 这种写法。"""
|
||||
if isinstance(value, str) and not value.startswith("["):
|
||||
return [item.strip() for item in value.split(",") if item.strip()]
|
||||
return value
|
||||
|
||||
def validate_runtime(self) -> None:
|
||||
"""生产环境的致命配置错误,必须在启动时炸掉,而不是等待请求。"""
|
||||
if self.env != "prod":
|
||||
return
|
||||
problems: list[str] = []
|
||||
if self.jwt_secret == DEV_JWT_SECRET or len(self.jwt_secret) < 32:
|
||||
problems.append("JWT_SECRET 仍是开发默认值或长度不足 32")
|
||||
if "dadao_dev_pw" in self.database_url:
|
||||
problems.append("DATABASE_URL 仍在用开发默认口令")
|
||||
if self.sms_provider == "dev":
|
||||
problems.append("生产环境不允许 SMS_PROVIDER=dev(任意验证码可登录)")
|
||||
if problems:
|
||||
raise RuntimeError("配置校验失败:" + ";".join(problems))
|
||||
|
||||
|
||||
@lru_cache
|
||||
def get_settings() -> Settings:
|
||||
return Settings()
|
||||
|
||||
|
||||
def build_commit() -> str:
|
||||
"""优先读环境变量(CI/镜像注入),退回 git。"""
|
||||
env_value = os.getenv("BUILD_COMMIT")
|
||||
if env_value:
|
||||
return env_value
|
||||
return get_settings().build_commit
|
||||
@@ -0,0 +1,94 @@
|
||||
"""数据库会话与租户上下文。
|
||||
|
||||
安全模型(务必理解,否则会写出越权 bug):
|
||||
|
||||
1. 应用进程用 **dadao_app** 角色连库 —— 非 owner、非 superuser、非 bypassrls。
|
||||
owner/superuser 会**绕过 RLS**,所以应用绝不使用迁移角色。
|
||||
2. 每个请求在**同一个事务**里先 `set_config('app.ws_id', ..., true)`
|
||||
(true = 事务级,事务结束自动失效,不会污染连接池里的下一条请求)。
|
||||
3. RLS 策略拿这个变量做过滤。变量没设 → 策略为 false → 一行都查不到。
|
||||
4. 仓储层强制 ws_id 必填,双保险:即使 RLS 被误关,代码也不会跨租户查。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from collections.abc import AsyncIterator
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import text
|
||||
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
|
||||
from sqlalchemy.pool import NullPool
|
||||
|
||||
from .config import get_settings
|
||||
from .errors import AppError, ErrorCode
|
||||
|
||||
_settings = get_settings()
|
||||
|
||||
_engine_kwargs: dict[str, Any] = {"echo": _settings.db_echo, "pool_pre_ping": True}
|
||||
if _settings.env == "test":
|
||||
# pytest-asyncio 每个用例一个事件循环,连接池跨循环复用会报
|
||||
# "attached to a different loop",测试环境干脆不池化。
|
||||
_engine_kwargs["poolclass"] = NullPool
|
||||
else:
|
||||
_engine_kwargs["pool_size"] = _settings.db_pool_size
|
||||
|
||||
engine = create_async_engine(_settings.database_url, **_engine_kwargs)
|
||||
SessionFactory = async_sessionmaker(engine, expire_on_commit=False, autoflush=False)
|
||||
|
||||
|
||||
async def get_session() -> AsyncIterator[AsyncSession]:
|
||||
"""一个请求 = 一个事务。异常即整体回滚。"""
|
||||
async with SessionFactory() as session:
|
||||
async with session.begin():
|
||||
yield session
|
||||
|
||||
|
||||
async def bind_tenant(session: AsyncSession, ws_id: uuid.UUID, user_id: uuid.UUID | None) -> None:
|
||||
"""把租户上下文写进当前事务。必须在任何业务查询之前调用。"""
|
||||
await session.execute(
|
||||
text("SELECT set_config('app.ws_id', :ws, true)"), {"ws": str(ws_id)}
|
||||
)
|
||||
if user_id is not None:
|
||||
await session.execute(
|
||||
text("SELECT set_config('app.user_id', :uid, true)"), {"uid": str(user_id)}
|
||||
)
|
||||
|
||||
|
||||
async def bind_user(session: AsyncSession, user_id: uuid.UUID) -> None:
|
||||
"""只绑用户(登录后尚未选定工作区的场景)。"""
|
||||
await session.execute(
|
||||
text("SELECT set_config('app.user_id', :uid, true)"), {"uid": str(user_id)}
|
||||
)
|
||||
|
||||
|
||||
async def current_app_role(session: AsyncSession) -> dict[str, Any]:
|
||||
"""自检用:确认应用连接的不是特权角色。"""
|
||||
row = (
|
||||
await session.execute(
|
||||
text(
|
||||
"SELECT current_user AS role, "
|
||||
"pg_has_role(current_user, 'pg_read_all_data', 'member') AS read_all, "
|
||||
"(SELECT rolsuper FROM pg_roles WHERE rolname = current_user) AS is_super, "
|
||||
"(SELECT rolbypassrls FROM pg_roles WHERE rolname = current_user) AS bypass_rls"
|
||||
)
|
||||
)
|
||||
).mappings().one()
|
||||
return dict(row)
|
||||
|
||||
|
||||
class Repo:
|
||||
"""仓储基类:ws_id 必填,且没有默认值——不给"忘了传"留余地。"""
|
||||
|
||||
def __init__(self, session: AsyncSession, ws_id: uuid.UUID) -> None:
|
||||
if ws_id is None:
|
||||
raise AppError(ErrorCode.BAD_REQUEST, "缺少工作区上下文")
|
||||
self.session = session
|
||||
self.ws_id = ws_id
|
||||
|
||||
|
||||
class UserRepo:
|
||||
"""全局仓储(app_user 无租户),仅认证流程使用。"""
|
||||
|
||||
def __init__(self, session: AsyncSession) -> None:
|
||||
self.session = session
|
||||
@@ -0,0 +1,114 @@
|
||||
"""请求依赖:认证、租户上下文、角色校验。
|
||||
|
||||
关键点:**租户上下文必须在任何业务查询之前绑定**,否则 RLS 会返回空集,
|
||||
表现为"数据凭空消失"而不是报错——这类 bug 最难查,所以封装成依赖强制调用。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from dataclasses import dataclass
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import Depends, Header, Path
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from .db import bind_tenant, bind_user, get_session
|
||||
from .errors import AppError, ErrorCode
|
||||
from .logging_setup import user_id_ctx, workspace_id_ctx
|
||||
from .models import AppUser, Member
|
||||
from .security import decode_token
|
||||
|
||||
ROLE_RANK: dict[str, int] = {"owner": 4, "admin": 3, "member": 2, "guest": 1}
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class AuthUser:
|
||||
id: uuid.UUID
|
||||
phone: str
|
||||
nickname: str
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class TenantCtx:
|
||||
ws_id: uuid.UUID
|
||||
user: AuthUser
|
||||
role: str
|
||||
|
||||
def require_role(self, minimum: str) -> None:
|
||||
if ROLE_RANK.get(self.role, 0) < ROLE_RANK[minimum]:
|
||||
raise AppError(ErrorCode.INSUFFICIENT_ROLE, f"需要 {minimum} 及以上权限")
|
||||
|
||||
|
||||
SessionDep = Annotated[AsyncSession, Depends(get_session)]
|
||||
|
||||
|
||||
async def get_bearer_token(authorization: Annotated[str | None, Header()] = None) -> str:
|
||||
if not authorization or not authorization.lower().startswith("bearer "):
|
||||
raise AppError(ErrorCode.UNAUTHORIZED, "缺少访问令牌")
|
||||
return authorization.split(" ", 1)[1].strip()
|
||||
|
||||
|
||||
TokenDep = Annotated[str, Depends(get_bearer_token)]
|
||||
|
||||
|
||||
async def current_user(token: TokenDep, session: SessionDep) -> AuthUser:
|
||||
user_id = decode_token(token, "access")
|
||||
user = await session.get(AppUser, user_id)
|
||||
if user is None:
|
||||
raise AppError(ErrorCode.UNAUTHORIZED, "账号不存在")
|
||||
if user.status != "active":
|
||||
raise AppError(ErrorCode.ACCOUNT_DISABLED, "账号已停用")
|
||||
# 绑定 user_id:member 表策略允许"看到自己的成员记录",用于"我加入了哪些工作区"
|
||||
await bind_user(session, user.id)
|
||||
user_id_ctx.set(str(user.id))
|
||||
return AuthUser(id=user.id, phone=user.phone, nickname=user.nickname)
|
||||
|
||||
|
||||
CurrentUser = Annotated[AuthUser, Depends(current_user)]
|
||||
|
||||
|
||||
async def tenant_ctx(
|
||||
ws_id: Annotated[uuid.UUID, Path(alias="ws_id")],
|
||||
user: CurrentUser,
|
||||
session: SessionDep,
|
||||
) -> TenantCtx:
|
||||
"""仅绑定租户(不要求是成员)。用于"凭邀请码加入工作区"这类场景。"""
|
||||
await bind_tenant(session, ws_id, user.id)
|
||||
workspace_id_ctx.set(str(ws_id))
|
||||
return TenantCtx(ws_id=ws_id, user=user, role="")
|
||||
|
||||
|
||||
TenantDep = Annotated[TenantCtx, Depends(tenant_ctx)]
|
||||
|
||||
|
||||
async def member_ctx(ctx: TenantDep, session: SessionDep) -> TenantCtx:
|
||||
"""要求是有效成员,并把角色带进上下文。"""
|
||||
row = await session.execute(
|
||||
select(Member).where(
|
||||
Member.workspace_id == ctx.ws_id,
|
||||
Member.user_id == ctx.user.id,
|
||||
Member.status == "active",
|
||||
)
|
||||
)
|
||||
member = row.scalar_one_or_none()
|
||||
if member is None:
|
||||
# 不区分"工作区不存在"和"你不是成员",避免探测他人工作区是否存在
|
||||
raise AppError(ErrorCode.NOT_A_MEMBER, "无权访问该工作区")
|
||||
return TenantCtx(ws_id=ctx.ws_id, user=ctx.user, role=member.role)
|
||||
|
||||
|
||||
MemberDep = Annotated[TenantCtx, Depends(member_ctx)]
|
||||
|
||||
|
||||
def require_role(minimum: str):
|
||||
async def _guard(ctx: MemberDep) -> TenantCtx:
|
||||
ctx.require_role(minimum)
|
||||
return ctx
|
||||
|
||||
return _guard
|
||||
|
||||
|
||||
OwnerDep = Annotated[TenantCtx, Depends(require_role("owner"))]
|
||||
AdminDep = Annotated[TenantCtx, Depends(require_role("admin"))]
|
||||
@@ -0,0 +1,84 @@
|
||||
"""错误码与异常。错误码一旦对外发布就**不许改号**(客户端靠它做分支)。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from enum import IntEnum
|
||||
from typing import Any
|
||||
|
||||
|
||||
class ErrorCode(IntEnum):
|
||||
OK = 0
|
||||
|
||||
# 1xxx 通用
|
||||
BAD_REQUEST = 1001
|
||||
VALIDATION_FAILED = 1002
|
||||
NOT_FOUND = 1004
|
||||
CONFLICT = 1009
|
||||
PAYLOAD_TOO_LARGE = 1013
|
||||
RATE_LIMITED = 1029
|
||||
INTERNAL = 1500
|
||||
|
||||
# 2xxx 认证 / 账号
|
||||
UNAUTHORIZED = 2001
|
||||
TOKEN_EXPIRED = 2002
|
||||
TOKEN_INVALID = 2003
|
||||
SMS_CODE_INVALID = 2010
|
||||
SMS_TOO_FREQUENT = 2011
|
||||
ACCOUNT_DISABLED = 2012
|
||||
|
||||
# 3xxx 租户 / 权限
|
||||
WORKSPACE_NOT_FOUND = 3001
|
||||
NOT_A_MEMBER = 3003
|
||||
INSUFFICIENT_ROLE = 3004
|
||||
ALREADY_A_MEMBER = 3005
|
||||
INVITATION_INVALID = 3010
|
||||
|
||||
# 4xxx 并发
|
||||
VERSION_CONFLICT = 4009
|
||||
|
||||
|
||||
# 默认 HTTP 状态:业务码自己带语义,HTTP 只是传输层
|
||||
_HTTP_STATUS: dict[ErrorCode, int] = {
|
||||
ErrorCode.OK: 200,
|
||||
ErrorCode.BAD_REQUEST: 400,
|
||||
ErrorCode.VALIDATION_FAILED: 422,
|
||||
ErrorCode.NOT_FOUND: 404,
|
||||
ErrorCode.CONFLICT: 409,
|
||||
ErrorCode.PAYLOAD_TOO_LARGE: 413,
|
||||
ErrorCode.RATE_LIMITED: 429,
|
||||
ErrorCode.INTERNAL: 500,
|
||||
ErrorCode.UNAUTHORIZED: 401,
|
||||
ErrorCode.TOKEN_EXPIRED: 401,
|
||||
ErrorCode.TOKEN_INVALID: 401,
|
||||
ErrorCode.SMS_CODE_INVALID: 400,
|
||||
ErrorCode.SMS_TOO_FREQUENT: 429,
|
||||
ErrorCode.ACCOUNT_DISABLED: 403,
|
||||
ErrorCode.WORKSPACE_NOT_FOUND: 404,
|
||||
ErrorCode.NOT_A_MEMBER: 403,
|
||||
ErrorCode.INSUFFICIENT_ROLE: 403,
|
||||
ErrorCode.ALREADY_A_MEMBER: 409,
|
||||
ErrorCode.INVITATION_INVALID: 400,
|
||||
ErrorCode.VERSION_CONFLICT: 409,
|
||||
}
|
||||
|
||||
|
||||
class AppError(Exception):
|
||||
"""业务异常。__str__ 只用于日志,对外一律走 code + message。"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
code: ErrorCode,
|
||||
message: str | None = None,
|
||||
*,
|
||||
http_status: int | None = None,
|
||||
details: Any = None,
|
||||
) -> None:
|
||||
self.code = code
|
||||
self.message = message or code.name
|
||||
self.http_status = http_status or _HTTP_STATUS.get(code, 400)
|
||||
self.details = details
|
||||
super().__init__(f"[{int(code)}] {self.message}")
|
||||
|
||||
|
||||
def http_status_for(code: ErrorCode) -> int:
|
||||
return _HTTP_STATUS.get(code, 400)
|
||||
@@ -0,0 +1,80 @@
|
||||
"""结构化日志:JSON 行 + request_id 贯穿 + 敏感字段脱敏。
|
||||
|
||||
脱敏是**无条件**的:任何日志调用里的 dict 都会过一遍 _mask。
|
||||
不要为了方便调试关掉它——AGENTS.md 第 7 条。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import sys
|
||||
from contextvars import ContextVar
|
||||
from datetime import UTC, datetime
|
||||
from typing import Any
|
||||
|
||||
request_id_ctx: ContextVar[str] = ContextVar("request_id", default="-")
|
||||
user_id_ctx: ContextVar[str] = ContextVar("user_id", default="-")
|
||||
workspace_id_ctx: ContextVar[str] = ContextVar("workspace_id", default="-")
|
||||
|
||||
_SENSITIVE_HINTS = (
|
||||
"password",
|
||||
"passwd",
|
||||
"token",
|
||||
"secret",
|
||||
"api_key",
|
||||
"apikey",
|
||||
"authorization",
|
||||
"cookie",
|
||||
"credential",
|
||||
"private_key",
|
||||
)
|
||||
MASK = "***"
|
||||
|
||||
|
||||
def _mask(value: Any, depth: int = 0) -> Any:
|
||||
if depth > 6:
|
||||
return "<deep>"
|
||||
if isinstance(value, dict):
|
||||
return {
|
||||
key: (MASK if any(h in str(key).lower() for h in _SENSITIVE_HINTS) else _mask(val, depth + 1))
|
||||
for key, val in value.items()
|
||||
}
|
||||
if isinstance(value, (list, tuple)):
|
||||
return [_mask(item, depth + 1) for item in value]
|
||||
return value
|
||||
|
||||
|
||||
class JsonFormatter(logging.Formatter):
|
||||
def format(self, record: logging.LogRecord) -> str:
|
||||
payload: dict[str, Any] = {
|
||||
"ts": datetime.now(UTC).isoformat(timespec="milliseconds"),
|
||||
"level": record.levelname,
|
||||
"logger": record.name,
|
||||
"msg": record.getMessage(),
|
||||
"request_id": request_id_ctx.get(),
|
||||
"user_id": user_id_ctx.get(),
|
||||
"ws_id": workspace_id_ctx.get(),
|
||||
}
|
||||
extra = getattr(record, "extra_fields", None)
|
||||
if isinstance(extra, dict):
|
||||
payload.update(_mask(extra))
|
||||
if record.exc_info:
|
||||
payload["exc"] = self.formatException(record.exc_info)
|
||||
return json.dumps(payload, ensure_ascii=False, default=str)
|
||||
|
||||
|
||||
def setup_logging(level: str = "INFO") -> None:
|
||||
handler = logging.StreamHandler(sys.stdout)
|
||||
handler.setFormatter(JsonFormatter())
|
||||
root = logging.getLogger()
|
||||
root.handlers = [handler]
|
||||
root.setLevel(level)
|
||||
# uvicorn 的 access log 交给自己的中间件,避免重复
|
||||
for noisy in ("uvicorn.access", "sqlalchemy.engine.Engine"):
|
||||
logging.getLogger(noisy).handlers = []
|
||||
logging.getLogger(noisy).propagate = False
|
||||
|
||||
|
||||
def get_logger(name: str) -> logging.Logger:
|
||||
return logging.getLogger(name)
|
||||
@@ -0,0 +1,132 @@
|
||||
"""FastAPI 入口。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from contextlib import asynccontextmanager
|
||||
from typing import Any
|
||||
|
||||
from fastapi import FastAPI, Request
|
||||
from fastapi.exceptions import RequestValidationError
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.responses import JSONResponse
|
||||
from sqlalchemy import text
|
||||
from starlette.exceptions import HTTPException as StarletteHTTPException
|
||||
|
||||
from .api import auth, workspaces
|
||||
from .config import get_settings
|
||||
from .db import SessionFactory, current_app_role, engine
|
||||
from .errors import AppError, ErrorCode, http_status_for
|
||||
from .logging_setup import get_logger, request_id_ctx, setup_logging
|
||||
from .middleware import IdempotencyMiddleware, RequestContextMiddleware, error_body
|
||||
|
||||
settings = get_settings()
|
||||
setup_logging("DEBUG" if settings.env == "dev" else "INFO")
|
||||
logger = get_logger("app")
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
settings.validate_runtime()
|
||||
logger.info("服务启动", extra={"extra_fields": {"env": settings.env, "commit": settings.build_commit}})
|
||||
yield
|
||||
await engine.dispose()
|
||||
|
||||
|
||||
app = FastAPI(
|
||||
title=f"{settings.app_name} API",
|
||||
version="0.1.0",
|
||||
description="达道Task —— 任务 / 工单 / 知识库,一条流。",
|
||||
lifespan=lifespan,
|
||||
docs_url="/docs" if settings.env != "prod" else None,
|
||||
redoc_url=None,
|
||||
)
|
||||
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=settings.cors_origins,
|
||||
allow_credentials=True,
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
expose_headers=["X-Request-Id", "X-Idempotent-Replay"],
|
||||
)
|
||||
# 后加的中间件先执行:RequestContext 必须最外层
|
||||
app.add_middleware(IdempotencyMiddleware)
|
||||
app.add_middleware(RequestContextMiddleware)
|
||||
|
||||
|
||||
# ── 异常处理:对外永远是 {code, message, data, request_id} ──
|
||||
@app.exception_handler(AppError)
|
||||
async def handle_app_error(request: Request, exc: AppError) -> JSONResponse:
|
||||
return JSONResponse(
|
||||
status_code=exc.http_status,
|
||||
content=error_body(request_id_ctx.get(), exc.code, exc.message, exc.details),
|
||||
)
|
||||
|
||||
|
||||
@app.exception_handler(RequestValidationError)
|
||||
async def handle_validation(request: Request, exc: RequestValidationError) -> JSONResponse:
|
||||
details: list[dict[str, Any]] = [
|
||||
{"field": ".".join(str(p) for p in err["loc"][1:]), "msg": err["msg"]}
|
||||
for err in exc.errors()
|
||||
]
|
||||
return JSONResponse(
|
||||
status_code=422,
|
||||
content=error_body(
|
||||
request_id_ctx.get(), ErrorCode.VALIDATION_FAILED, "参数校验失败", details
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@app.exception_handler(StarletteHTTPException)
|
||||
async def handle_http(request: Request, exc: StarletteHTTPException) -> JSONResponse:
|
||||
code = ErrorCode.NOT_FOUND if exc.status_code == 404 else ErrorCode.BAD_REQUEST
|
||||
return JSONResponse(
|
||||
status_code=exc.status_code,
|
||||
content=error_body(request_id_ctx.get(), code, str(exc.detail)),
|
||||
)
|
||||
|
||||
|
||||
@app.exception_handler(Exception)
|
||||
async def handle_unexpected(request: Request, exc: Exception) -> JSONResponse:
|
||||
logger.exception("未处理异常")
|
||||
return JSONResponse(
|
||||
status_code=http_status_for(ErrorCode.INTERNAL),
|
||||
content=error_body(request_id_ctx.get(), ErrorCode.INTERNAL, "服务器内部错误"),
|
||||
)
|
||||
|
||||
|
||||
app.include_router(auth.router, prefix=settings.api_prefix)
|
||||
app.include_router(workspaces.router, prefix=settings.api_prefix)
|
||||
|
||||
|
||||
@app.get("/health", tags=["运维"], summary="存活探针")
|
||||
async def health() -> dict:
|
||||
db_ok = True
|
||||
try:
|
||||
async with SessionFactory() as session:
|
||||
await session.execute(text("SELECT 1"))
|
||||
except Exception: # noqa: BLE001
|
||||
logger.exception("数据库探活失败")
|
||||
db_ok = False
|
||||
return {"status": "ok" if db_ok else "degraded", "db": db_ok, "env": settings.env}
|
||||
|
||||
|
||||
@app.get("/version", tags=["运维"], summary="构建标记")
|
||||
async def version() -> dict:
|
||||
"""部署后必须校验:commit 必须等于 git rev-parse --short HEAD。"""
|
||||
role = None
|
||||
try:
|
||||
async with SessionFactory() as session:
|
||||
role = await current_app_role(session)
|
||||
except Exception: # noqa: BLE001
|
||||
logger.warning("读取数据库角色失败")
|
||||
|
||||
return {
|
||||
"app": settings.app_name,
|
||||
"env": settings.env,
|
||||
"commit": settings.build_commit,
|
||||
"build_time": settings.build_time,
|
||||
"api_prefix": settings.api_prefix,
|
||||
"db_role": role,
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
"""中间件:请求上下文(request_id / 访问日志)与幂等键。
|
||||
|
||||
顺序:RequestContext → Idempotency → 路由。
|
||||
request_id 必须最先建立,否则后面所有日志都没有 trace。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import time
|
||||
import uuid
|
||||
from typing import Any
|
||||
|
||||
from fastapi import Request, Response
|
||||
from starlette.middleware.base import BaseHTTPMiddleware, RequestResponseEndpoint
|
||||
from starlette.responses import JSONResponse
|
||||
|
||||
from .config import get_settings
|
||||
from .errors import ErrorCode
|
||||
from .logging_setup import get_logger, request_id_ctx
|
||||
from .security import decode_token
|
||||
from .stores import dumps, get_store, idempotency_key
|
||||
|
||||
settings = get_settings()
|
||||
logger = get_logger("app.access")
|
||||
|
||||
_IDEMPOTENT_METHODS = {"POST", "PUT", "PATCH", "DELETE"}
|
||||
_MAX_CACHE_BYTES = 256 * 1024
|
||||
|
||||
|
||||
class RequestContextMiddleware(BaseHTTPMiddleware):
|
||||
async def dispatch(self, request: Request, call_next: RequestResponseEndpoint) -> Response:
|
||||
rid = request.headers.get("X-Request-Id") or uuid.uuid4().hex
|
||||
token = request_id_ctx.set(rid)
|
||||
started = time.perf_counter()
|
||||
status = 500
|
||||
try:
|
||||
response = await call_next(request)
|
||||
status = response.status_code
|
||||
response.headers["X-Request-Id"] = rid
|
||||
return response
|
||||
finally:
|
||||
elapsed_ms = round((time.perf_counter() - started) * 1000, 2)
|
||||
logger.info(
|
||||
"%s %s %s %sms",
|
||||
request.method,
|
||||
request.url.path,
|
||||
status,
|
||||
elapsed_ms,
|
||||
extra={
|
||||
"extra_fields": {
|
||||
"method": request.method,
|
||||
"path": request.url.path,
|
||||
"status": status,
|
||||
"elapsed_ms": elapsed_ms,
|
||||
"client": request.client.host if request.client else "-",
|
||||
}
|
||||
},
|
||||
)
|
||||
request_id_ctx.reset(token)
|
||||
|
||||
|
||||
class IdempotencyMiddleware(BaseHTTPMiddleware):
|
||||
"""带 Idempotency-Key 的写请求,重复提交返回第一次的结果。
|
||||
|
||||
限制(M0 已知):同 key 并发请求存在竞态窗口,靠唯一键约束兜底留到业务层;
|
||||
只缓存 JSON 且 < 256KB 的响应;5xx 不缓存。
|
||||
"""
|
||||
|
||||
async def dispatch(self, request: Request, call_next: RequestResponseEndpoint) -> Response:
|
||||
if not settings.idempotency_enabled or request.method not in _IDEMPOTENT_METHODS:
|
||||
return await call_next(request)
|
||||
|
||||
raw_key = request.headers.get("Idempotency-Key")
|
||||
if not raw_key:
|
||||
return await call_next(request)
|
||||
|
||||
subject = self._subject(request)
|
||||
if subject is None:
|
||||
# 未认证请求不做幂等(登录接口另有验证码限流)
|
||||
return await call_next(request)
|
||||
|
||||
store = get_store()
|
||||
cache_key = idempotency_key(subject, raw_key)
|
||||
try:
|
||||
cached = await store.get(cache_key)
|
||||
except Exception: # noqa: BLE001 - 存储不可用不能拖垮业务
|
||||
logger.warning("幂等存储不可用,降级为直接执行", exc_info=True)
|
||||
return await call_next(request)
|
||||
|
||||
if cached:
|
||||
payload = json.loads(cached)
|
||||
return JSONResponse(
|
||||
content=payload["body"],
|
||||
status_code=payload["status"],
|
||||
headers={"X-Idempotent-Replay": "1"},
|
||||
)
|
||||
|
||||
response = await call_next(request)
|
||||
if response.status_code >= 500:
|
||||
return response
|
||||
|
||||
body = b""
|
||||
async for chunk in response.body_iterator: # type: ignore[attr-defined]
|
||||
body += chunk.encode() if isinstance(chunk, str) else chunk
|
||||
if len(body) <= _MAX_CACHE_BYTES and "application/json" in response.headers.get(
|
||||
"content-type", ""
|
||||
):
|
||||
try:
|
||||
await store.set(
|
||||
cache_key,
|
||||
dumps({"status": response.status_code, "body": json.loads(body)}),
|
||||
settings.idempotency_ttl_seconds,
|
||||
)
|
||||
except Exception: # noqa: BLE001
|
||||
logger.warning("幂等结果写入失败", exc_info=True)
|
||||
|
||||
return Response(
|
||||
content=body,
|
||||
status_code=response.status_code,
|
||||
headers=dict(response.headers),
|
||||
media_type=response.headers.get("content-type"),
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _subject(request: Request) -> str | None:
|
||||
header = request.headers.get("Authorization", "")
|
||||
if not header.lower().startswith("bearer "):
|
||||
return None
|
||||
try:
|
||||
return str(decode_token(header.split(" ", 1)[1].strip(), "access"))
|
||||
except Exception: # noqa: BLE001 - 令牌问题交给后面的依赖统一报错
|
||||
return None
|
||||
|
||||
|
||||
def error_body(request_id: str, code: ErrorCode, message: str, details: Any = None) -> dict:
|
||||
return {
|
||||
"code": int(code),
|
||||
"message": message,
|
||||
"data": details,
|
||||
"request_id": request_id,
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
"""数据模型(M0 范围:workspace / user / member / invitation / audit_log)。
|
||||
|
||||
M1 之后的任务、工单、知识表另起模块,但**继承同样的约定**:
|
||||
- 主键 uuid,应用侧生成(不依赖数据库扩展,方便迁移与分库)
|
||||
- 通用列 created_at / updated_at / version / deleted_at
|
||||
- 所有租户表必须有 workspace_id NOT NULL(AGENTS.md 第 2.5 条)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import DateTime, ForeignKey, Index, String, UniqueConstraint, func
|
||||
from sqlalchemy.dialects.postgresql import JSONB
|
||||
from sqlalchemy.dialects.postgresql import UUID as PgUUID
|
||||
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column
|
||||
|
||||
|
||||
class Base(DeclarativeBase):
|
||||
pass
|
||||
|
||||
|
||||
def new_uuid() -> uuid.UUID:
|
||||
return uuid.uuid4()
|
||||
|
||||
|
||||
class Timestamps:
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), nullable=False, server_default=func.now()
|
||||
)
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), nullable=False, server_default=func.now(), onupdate=func.now()
|
||||
)
|
||||
version: Mapped[int] = mapped_column(nullable=False, default=1, server_default="1")
|
||||
|
||||
|
||||
class AppUser(Base, Timestamps):
|
||||
"""全局用户,**不挂租户**(一个人可以属于多个工作区)。"""
|
||||
|
||||
__tablename__ = "app_user"
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(PgUUID(as_uuid=True), primary_key=True, default=new_uuid)
|
||||
phone: Mapped[str] = mapped_column(String(32), nullable=False, unique=True)
|
||||
nickname: Mapped[str] = mapped_column(String(64), nullable=False, default="")
|
||||
avatar_url: Mapped[str | None] = mapped_column(String(512))
|
||||
status: Mapped[str] = mapped_column(String(16), nullable=False, default="active")
|
||||
|
||||
|
||||
class Workspace(Base, Timestamps):
|
||||
"""租户根。RLS 策略:workspace_id(id) = current_setting('app.ws_id')。"""
|
||||
|
||||
__tablename__ = "workspace"
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(PgUUID(as_uuid=True), primary_key=True, default=new_uuid)
|
||||
name: Mapped[str] = mapped_column(String(64), nullable=False)
|
||||
slug: Mapped[str] = mapped_column(String(64), nullable=False, unique=True)
|
||||
plan: Mapped[str] = mapped_column(String(16), nullable=False, default="free")
|
||||
owner_id: Mapped[uuid.UUID] = mapped_column(
|
||||
PgUUID(as_uuid=True), ForeignKey("app_user.id", ondelete="RESTRICT"), nullable=False
|
||||
)
|
||||
deleted_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||
|
||||
|
||||
class Member(Base, Timestamps):
|
||||
"""成员关系。RLS 策略:本租户可见 OR 自己是本人(用于"我加入了哪些工作区")。"""
|
||||
|
||||
__tablename__ = "member"
|
||||
__table_args__ = (
|
||||
UniqueConstraint("workspace_id", "user_id", name="uq_member_ws_user"),
|
||||
Index("ix_member_user", "user_id"),
|
||||
)
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(PgUUID(as_uuid=True), primary_key=True, default=new_uuid)
|
||||
workspace_id: Mapped[uuid.UUID] = mapped_column(
|
||||
PgUUID(as_uuid=True), ForeignKey("workspace.id", ondelete="CASCADE"), nullable=False
|
||||
)
|
||||
user_id: Mapped[uuid.UUID] = mapped_column(
|
||||
PgUUID(as_uuid=True), ForeignKey("app_user.id", ondelete="CASCADE"), nullable=False
|
||||
)
|
||||
role: Mapped[str] = mapped_column(String(16), nullable=False, default="member")
|
||||
status: Mapped[str] = mapped_column(String(16), nullable=False, default="active")
|
||||
joined_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), nullable=False, server_default=func.now()
|
||||
)
|
||||
|
||||
|
||||
class Invitation(Base, Timestamps):
|
||||
__tablename__ = "invitation"
|
||||
__table_args__ = (
|
||||
UniqueConstraint("code", name="uq_invitation_code"),
|
||||
Index("ix_invitation_ws", "workspace_id"),
|
||||
)
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(PgUUID(as_uuid=True), primary_key=True, default=new_uuid)
|
||||
workspace_id: Mapped[uuid.UUID] = mapped_column(
|
||||
PgUUID(as_uuid=True), ForeignKey("workspace.id", ondelete="CASCADE"), nullable=False
|
||||
)
|
||||
code: Mapped[str] = mapped_column(String(32), nullable=False)
|
||||
role: Mapped[str] = mapped_column(String(16), nullable=False, default="member")
|
||||
invited_by: Mapped[uuid.UUID] = mapped_column(
|
||||
PgUUID(as_uuid=True), ForeignKey("app_user.id", ondelete="RESTRICT"), nullable=False
|
||||
)
|
||||
expires_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
|
||||
accepted_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||
accepted_by: Mapped[uuid.UUID | None] = mapped_column(
|
||||
PgUUID(as_uuid=True), ForeignKey("app_user.id", ondelete="SET NULL")
|
||||
)
|
||||
|
||||
|
||||
class AuditLog(Base):
|
||||
"""审计日志。**只增不改**;租户内可见,平台侧另有运维通道(M7)。"""
|
||||
|
||||
__tablename__ = "audit_log"
|
||||
__table_args__ = (Index("ix_audit_ws_created", "workspace_id", "created_at"),)
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(PgUUID(as_uuid=True), primary_key=True, default=new_uuid)
|
||||
workspace_id: Mapped[uuid.UUID | None] = mapped_column(
|
||||
PgUUID(as_uuid=True), ForeignKey("workspace.id", ondelete="SET NULL")
|
||||
)
|
||||
actor_user_id: Mapped[uuid.UUID | None] = mapped_column(
|
||||
PgUUID(as_uuid=True), ForeignKey("app_user.id", ondelete="SET NULL")
|
||||
)
|
||||
action: Mapped[str] = mapped_column(String(64), nullable=False)
|
||||
target_type: Mapped[str] = mapped_column(String(32), nullable=False, default="")
|
||||
target_id: Mapped[str] = mapped_column(String(64), nullable=False, default="")
|
||||
payload: Mapped[dict | None] = mapped_column(JSONB)
|
||||
ip: Mapped[str] = mapped_column(String(64), nullable=False, default="")
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), nullable=False, server_default=func.now()
|
||||
)
|
||||
|
||||
|
||||
# 需要 RLS 的租户表(迁移与测试共用这一份清单,避免漏加)
|
||||
TENANT_TABLES: tuple[str, ...] = ("workspace", "member", "invitation", "audit_log")
|
||||
@@ -0,0 +1,77 @@
|
||||
"""统一响应体 {code, message, data, request_id} + 游标分页。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import binascii
|
||||
import json
|
||||
from datetime import datetime
|
||||
from typing import Any, Generic, TypeVar
|
||||
from uuid import UUID
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from .errors import AppError, ErrorCode
|
||||
from .logging_setup import request_id_ctx
|
||||
|
||||
T = TypeVar("T")
|
||||
|
||||
DEFAULT_LIMIT = 20
|
||||
MAX_LIMIT = 100
|
||||
|
||||
|
||||
class Envelope(BaseModel, Generic[T]):
|
||||
code: int = 0
|
||||
message: str = "ok"
|
||||
data: T | None = None
|
||||
request_id: str = "-"
|
||||
|
||||
|
||||
class Page(BaseModel, Generic[T]):
|
||||
items: list[T]
|
||||
next_cursor: str | None = None
|
||||
has_more: bool = False
|
||||
|
||||
|
||||
class Cursor(BaseModel):
|
||||
"""(created_at, id) 复合游标:单用 created_at 会在同毫秒记录上漏数据。"""
|
||||
|
||||
created_at: datetime
|
||||
id: UUID
|
||||
|
||||
|
||||
def ok(data: Any = None, message: str = "ok") -> dict[str, Any]:
|
||||
return {
|
||||
"code": int(ErrorCode.OK),
|
||||
"message": message,
|
||||
"data": data,
|
||||
"request_id": request_id_ctx.get(),
|
||||
}
|
||||
|
||||
|
||||
def encode_cursor(created_at: datetime, row_id: UUID) -> str:
|
||||
raw = json.dumps({"t": created_at.isoformat(), "i": str(row_id)}, separators=(",", ":"))
|
||||
return base64.urlsafe_b64encode(raw.encode()).decode().rstrip("=")
|
||||
|
||||
|
||||
def decode_cursor(cursor: str) -> Cursor:
|
||||
try:
|
||||
padded = cursor + "=" * (-len(cursor) % 4)
|
||||
payload = json.loads(base64.urlsafe_b64decode(padded))
|
||||
return Cursor(created_at=datetime.fromisoformat(payload["t"]), id=UUID(payload["i"]))
|
||||
except (binascii.Error, KeyError, ValueError, json.JSONDecodeError) as exc:
|
||||
raise AppError(ErrorCode.BAD_REQUEST, "分页游标不合法") from exc
|
||||
|
||||
|
||||
def clamp_limit(limit: int) -> int:
|
||||
if limit <= 0:
|
||||
return DEFAULT_LIMIT
|
||||
return min(limit, MAX_LIMIT)
|
||||
|
||||
|
||||
class Pagination(BaseModel):
|
||||
limit: int = Field(default=DEFAULT_LIMIT, ge=1, le=MAX_LIMIT)
|
||||
cursor: str | None = None
|
||||
|
||||
def parsed(self) -> tuple[int, Cursor | None]:
|
||||
return clamp_limit(self.limit), (decode_cursor(self.cursor) if self.cursor else None)
|
||||
@@ -0,0 +1,80 @@
|
||||
"""令牌与验证码。
|
||||
|
||||
安全约定:
|
||||
- access token 短期(默认 30 分钟),refresh token 长期(14 天),两者 type 不同,不可互换。
|
||||
- 令牌里不放手机号等 PII,只放 sub=user_id。
|
||||
- 验证码只在服务端比对,**不写日志**;开发环境返回固定码仅用于本地联调。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hmac
|
||||
import secrets
|
||||
import time
|
||||
import uuid
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, Literal
|
||||
|
||||
import jwt
|
||||
|
||||
from .config import get_settings
|
||||
from .errors import AppError, ErrorCode
|
||||
|
||||
settings = get_settings()
|
||||
|
||||
TokenType = Literal["access", "refresh"]
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class TokenPair:
|
||||
access_token: str
|
||||
refresh_token: str
|
||||
expires_in: int
|
||||
|
||||
|
||||
def _new_token(user_id: uuid.UUID, token_type: TokenType, ttl_seconds: int) -> str:
|
||||
now = int(time.time())
|
||||
payload: dict[str, Any] = {
|
||||
"sub": str(user_id),
|
||||
"typ": token_type,
|
||||
"iat": now,
|
||||
"exp": now + ttl_seconds,
|
||||
"jti": secrets.token_urlsafe(8),
|
||||
}
|
||||
return jwt.encode(payload, settings.jwt_secret, algorithm=settings.jwt_algorithm)
|
||||
|
||||
|
||||
def create_token_pair(user_id: uuid.UUID) -> TokenPair:
|
||||
access_ttl = settings.access_token_minutes * 60
|
||||
return TokenPair(
|
||||
access_token=_new_token(user_id, "access", access_ttl),
|
||||
refresh_token=_new_token(user_id, "refresh", settings.refresh_token_days * 86400),
|
||||
expires_in=access_ttl,
|
||||
)
|
||||
|
||||
|
||||
def decode_token(token: str, expect: TokenType) -> uuid.UUID:
|
||||
try:
|
||||
payload = jwt.decode(token, settings.jwt_secret, algorithms=[settings.jwt_algorithm])
|
||||
except jwt.ExpiredSignatureError as exc:
|
||||
raise AppError(ErrorCode.TOKEN_EXPIRED, "登录已过期,请重新登录") from exc
|
||||
except jwt.PyJWTError as exc:
|
||||
raise AppError(ErrorCode.TOKEN_INVALID, "令牌无效") from exc
|
||||
|
||||
if payload.get("typ") != expect:
|
||||
# 用 refresh 当 access 用是常见攻击面,必须拦
|
||||
raise AppError(ErrorCode.TOKEN_INVALID, "令牌类型不匹配")
|
||||
try:
|
||||
return uuid.UUID(str(payload["sub"]))
|
||||
except (KeyError, ValueError) as exc:
|
||||
raise AppError(ErrorCode.TOKEN_INVALID, "令牌内容不合法") from exc
|
||||
|
||||
|
||||
def new_invite_code() -> str:
|
||||
# 去掉容易看错的 0/O/1/I
|
||||
alphabet = "23456789ABCDEFGHJKLMNPQRSTUVWXYZ"
|
||||
return "".join(secrets.choice(alphabet) for _ in range(8))
|
||||
|
||||
|
||||
def invite_code_matches(provided: str, expected: str) -> bool:
|
||||
return hmac.compare_digest(provided.strip().upper(), expected)
|
||||
@@ -0,0 +1,111 @@
|
||||
"""短生命周期状态存储:验证码、幂等键。
|
||||
|
||||
dev/test 用进程内实现(不依赖 Redis 也能跑测试),prod 强制走 Redis。
|
||||
接口刻意做小,将来换实现不影响调用方。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import time
|
||||
from typing import Protocol
|
||||
|
||||
from .config import get_settings
|
||||
|
||||
settings = get_settings()
|
||||
|
||||
|
||||
class KVStore(Protocol):
|
||||
async def set(self, key: str, value: str, ttl_seconds: int) -> None: ...
|
||||
|
||||
async def get(self, key: str) -> str | None: ...
|
||||
|
||||
async def delete(self, key: str) -> None: ...
|
||||
|
||||
async def incr(self, key: str, ttl_seconds: int) -> int: ...
|
||||
|
||||
|
||||
class MemoryStore:
|
||||
"""进程内 TTL 字典。仅限单进程 dev/test——多 worker 下不共享,别在生产用。"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._data: dict[str, tuple[str, float]] = {}
|
||||
|
||||
def _purge(self, key: str) -> None:
|
||||
item = self._data.get(key)
|
||||
if item and item[1] < time.time():
|
||||
self._data.pop(key, None)
|
||||
|
||||
async def set(self, key: str, value: str, ttl_seconds: int) -> None:
|
||||
self._data[key] = (value, time.time() + ttl_seconds)
|
||||
|
||||
async def get(self, key: str) -> str | None:
|
||||
self._purge(key)
|
||||
item = self._data.get(key)
|
||||
return item[0] if item else None
|
||||
|
||||
async def delete(self, key: str) -> None:
|
||||
self._data.pop(key, None)
|
||||
|
||||
async def incr(self, key: str, ttl_seconds: int) -> int:
|
||||
self._purge(key)
|
||||
current = int(self._data[key][0]) if key in self._data else 0
|
||||
current += 1
|
||||
expire_at = self._data[key][1] if key in self._data else time.time() + ttl_seconds
|
||||
self._data[key] = (str(current), expire_at)
|
||||
return current
|
||||
|
||||
|
||||
class RedisStore:
|
||||
def __init__(self, url: str) -> None:
|
||||
from redis.asyncio import Redis # 延迟导入:dev 环境没有 redis 包也能起
|
||||
|
||||
self._client = Redis.from_url(url, decode_responses=True)
|
||||
|
||||
async def set(self, key: str, value: str, ttl_seconds: int) -> None:
|
||||
await self._client.set(key, value, ex=ttl_seconds)
|
||||
|
||||
async def get(self, key: str) -> str | None:
|
||||
return await self._client.get(key)
|
||||
|
||||
async def delete(self, key: str) -> None:
|
||||
await self._client.delete(key)
|
||||
|
||||
async def incr(self, key: str, ttl_seconds: int) -> int:
|
||||
value = await self._client.incr(key)
|
||||
if value == 1:
|
||||
await self._client.expire(key, ttl_seconds)
|
||||
return int(value)
|
||||
|
||||
|
||||
_store: KVStore | None = None
|
||||
|
||||
|
||||
def get_store() -> KVStore:
|
||||
global _store
|
||||
if _store is None:
|
||||
_store = RedisStore(settings.redis_url) if settings.env == "prod" else MemoryStore()
|
||||
return _store
|
||||
|
||||
|
||||
def reset_store_for_tests() -> None:
|
||||
"""测试夹具用:清掉进程内状态。"""
|
||||
global _store
|
||||
_store = None
|
||||
|
||||
|
||||
def dumps(value: object) -> str:
|
||||
return json.dumps(value, ensure_ascii=False, separators=(",", ":"))
|
||||
|
||||
|
||||
# ── 业务键约定 ──────────────────────────────────────────
|
||||
def sms_code_key(phone: str) -> str:
|
||||
return f"sms:login:{phone}"
|
||||
|
||||
|
||||
def sms_throttle_key(phone: str) -> str:
|
||||
return f"sms:throttle:{phone}"
|
||||
|
||||
|
||||
def idempotency_key(user_id: str, key: str) -> str:
|
||||
return f"idem:{user_id}:{key}"
|
||||
@@ -0,0 +1,22 @@
|
||||
[tool.ruff]
|
||||
line-length = 100
|
||||
target-version = "py310"
|
||||
extend-exclude = ["alembic/versions"]
|
||||
|
||||
[tool.ruff.lint]
|
||||
select = ["E", "F", "I", "UP", "B", "ASYNC", "S", "C4", "SIM"]
|
||||
ignore = ["S101"] # 测试里允许 assert
|
||||
|
||||
[tool.ruff.lint.per-file-ignores]
|
||||
"tests/*" = ["S105", "S106"] # 测试里的假令牌/假口令
|
||||
"alembic/env.py" = ["E402"]
|
||||
|
||||
[tool.ruff.lint.isort]
|
||||
known-first-party = ["app"]
|
||||
|
||||
[tool.pytest.ini_options]
|
||||
asyncio_mode = "auto"
|
||||
asyncio_default_fixture_loop_scope = "function"
|
||||
testpaths = ["tests"]
|
||||
addopts = "-q --strict-markers"
|
||||
filterwarnings = ["ignore::DeprecationWarning"]
|
||||
@@ -0,0 +1,15 @@
|
||||
fastapi==0.115.6
|
||||
uvicorn[standard]==0.34.0
|
||||
sqlalchemy[asyncio]==2.0.36
|
||||
asyncpg==0.30.0
|
||||
alembic==1.14.0
|
||||
pydantic==2.10.4
|
||||
pydantic-settings==2.7.0
|
||||
pyjwt==2.10.1
|
||||
redis==5.2.1
|
||||
|
||||
# dev / test only (split into requirements-dev.txt when it grows)
|
||||
pytest==8.3.4
|
||||
pytest-asyncio==0.25.0
|
||||
httpx==0.28.1
|
||||
ruff==0.8.4
|
||||
@@ -0,0 +1,98 @@
|
||||
"""测试夹具。
|
||||
|
||||
约定:
|
||||
- 一律走真实 HTTP 栈(ASGITransport),不做 handler 级 mock —— 中间件/依赖/RLS 都是被测对象。
|
||||
- 每个用例前清库,避免串味;清库走**迁移角色**(app 角色没有 TRUNCATE 权限,这是设计使然)。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
|
||||
os.environ["ENV"] = "test"
|
||||
os.environ.setdefault("SMS_DEV_CODE", "123456")
|
||||
|
||||
import uuid
|
||||
from collections.abc import AsyncIterator
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
from alembic import command
|
||||
from alembic.config import Config
|
||||
from httpx import ASGITransport, AsyncClient
|
||||
from sqlalchemy import text
|
||||
from sqlalchemy.ext.asyncio import create_async_engine
|
||||
from sqlalchemy.pool import NullPool
|
||||
|
||||
from app.config import get_settings
|
||||
from app.db import engine
|
||||
from app.main import app
|
||||
from app.stores import reset_store_for_tests
|
||||
|
||||
settings = get_settings()
|
||||
DEV_CODE = settings.sms_dev_code
|
||||
|
||||
_TABLES = "audit_log, invitation, member, workspace, app_user"
|
||||
|
||||
|
||||
def _run_migrations() -> None:
|
||||
cfg = Config(str(os.path.join(os.path.dirname(__file__), "..", "alembic.ini")))
|
||||
command.upgrade(cfg, "head")
|
||||
|
||||
|
||||
@pytest.fixture(scope="session", autouse=True)
|
||||
def migrated_database() -> None:
|
||||
_run_migrations()
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
async def clean_database(migrated_database: None) -> AsyncIterator[None]:
|
||||
owner_engine = create_async_engine(settings.migration_database_url, poolclass=NullPool)
|
||||
async with owner_engine.begin() as conn:
|
||||
await conn.execute(text(f"TRUNCATE {_TABLES} CASCADE"))
|
||||
await owner_engine.dispose()
|
||||
reset_store_for_tests()
|
||||
yield
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
async def dispose_app_engine() -> AsyncIterator[None]:
|
||||
yield
|
||||
await engine.dispose()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def client() -> AsyncIterator[AsyncClient]:
|
||||
transport = ASGITransport(app=app)
|
||||
async with AsyncClient(transport=transport, base_url="http://test") as ac:
|
||||
yield ac
|
||||
|
||||
|
||||
def auth(token: str) -> dict[str, str]:
|
||||
return {"Authorization": f"Bearer {token}"}
|
||||
|
||||
|
||||
def unwrap(response: Any) -> Any:
|
||||
body = response.json()
|
||||
assert body.get("code") == 0, f"期望成功,实际 {body}"
|
||||
return body["data"]
|
||||
|
||||
|
||||
async def login(client: AsyncClient, phone: str | None = None) -> dict[str, Any]:
|
||||
"""发码 + 登录,返回 {token, refresh_token, user_id, phone}。"""
|
||||
phone = phone or f"138{uuid.uuid4().int % 100_000_000:08d}"
|
||||
await client.post("/api/v1/auth/sms/send", json={"phone": phone})
|
||||
data = unwrap(
|
||||
await client.post("/api/v1/auth/login", json={"phone": phone, "code": DEV_CODE})
|
||||
)
|
||||
return {
|
||||
"token": data["tokens"]["access_token"],
|
||||
"refresh_token": data["tokens"]["refresh_token"],
|
||||
"user_id": data["user"]["id"],
|
||||
"phone": phone,
|
||||
"headers": auth(data["tokens"]["access_token"]),
|
||||
}
|
||||
|
||||
|
||||
async def create_workspace(client: AsyncClient, user: dict[str, Any], name: str) -> dict[str, Any]:
|
||||
return unwrap(await client.post("/api/v1/workspaces", json={"name": name}, headers=user["headers"]))
|
||||
@@ -0,0 +1,88 @@
|
||||
"""认证相关。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from httpx import AsyncClient
|
||||
|
||||
from .conftest import DEV_CODE, auth, login, unwrap
|
||||
|
||||
|
||||
async def test_login_creates_user_and_me_works(client: AsyncClient) -> None:
|
||||
user = await login(client, "13800000001")
|
||||
me = unwrap(await client.get("/api/v1/auth/me", headers=user["headers"]))
|
||||
assert me["user"]["phone"] == "138****0001" # 手机号对外必须打码
|
||||
assert me["workspaces"] == []
|
||||
|
||||
|
||||
async def test_login_is_idempotent_for_same_phone(client: AsyncClient) -> None:
|
||||
first = await login(client, "13800000002")
|
||||
second = await login(client, "13800000002")
|
||||
assert first["user_id"] == second["user_id"]
|
||||
|
||||
|
||||
async def test_wrong_code_rejected(client: AsyncClient) -> None:
|
||||
await client.post("/api/v1/auth/sms/send", json={"phone": "13800000003"})
|
||||
resp = await client.post(
|
||||
"/api/v1/auth/login", json={"phone": "13800000003", "code": "000000"}
|
||||
)
|
||||
assert resp.status_code == 400
|
||||
assert resp.json()["code"] == 2010
|
||||
|
||||
|
||||
async def test_code_can_only_be_used_once(client: AsyncClient) -> None:
|
||||
await client.post("/api/v1/auth/sms/send", json={"phone": "13800000004"})
|
||||
ok = await client.post(
|
||||
"/api/v1/auth/login", json={"phone": "13800000004", "code": DEV_CODE}
|
||||
)
|
||||
assert ok.status_code == 200
|
||||
again = await client.post(
|
||||
"/api/v1/auth/login", json={"phone": "13800000004", "code": DEV_CODE}
|
||||
)
|
||||
assert again.json()["code"] == 2010
|
||||
|
||||
|
||||
async def test_sms_throttled(client: AsyncClient) -> None:
|
||||
payload = {"phone": "13800000005"}
|
||||
assert (await client.post("/api/v1/auth/sms/send", json=payload)).status_code == 200
|
||||
second = await client.post("/api/v1/auth/sms/send", json=payload)
|
||||
assert second.json()["code"] == 2011
|
||||
|
||||
|
||||
async def test_refresh_token_cannot_be_used_as_access_token(client: AsyncClient) -> None:
|
||||
user = await login(client)
|
||||
resp = await client.get("/api/v1/auth/me", headers=auth(user["refresh_token"]))
|
||||
assert resp.status_code == 401
|
||||
assert resp.json()["code"] == 2003
|
||||
|
||||
|
||||
async def test_access_token_cannot_be_used_to_refresh(client: AsyncClient) -> None:
|
||||
user = await login(client)
|
||||
resp = await client.post(
|
||||
"/api/v1/auth/refresh", json={"refresh_token": user["token"]}
|
||||
)
|
||||
assert resp.status_code == 401
|
||||
assert resp.json()["code"] == 2003
|
||||
|
||||
|
||||
async def test_refresh_returns_working_token_pair(client: AsyncClient) -> None:
|
||||
user = await login(client)
|
||||
data = unwrap(
|
||||
await client.post(
|
||||
"/api/v1/auth/refresh", json={"refresh_token": user["refresh_token"]}
|
||||
)
|
||||
)
|
||||
me = await client.get("/api/v1/auth/me", headers=auth(data["access_token"]))
|
||||
assert me.status_code == 200
|
||||
|
||||
|
||||
async def test_requires_token(client: AsyncClient) -> None:
|
||||
resp = await client.get("/api/v1/auth/me")
|
||||
assert resp.status_code == 401
|
||||
assert resp.json()["code"] == 2001
|
||||
assert resp.json()["request_id"] != "-"
|
||||
|
||||
|
||||
async def test_unknown_path_returns_envelope(client: AsyncClient) -> None:
|
||||
resp = await client.get("/api/v1/nope")
|
||||
assert resp.status_code == 404
|
||||
assert set(resp.json()) == {"code", "message", "data", "request_id"}
|
||||
@@ -0,0 +1,64 @@
|
||||
"""幂等键:客户端网络抖动重试时,不能建出两个工作区。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from httpx import AsyncClient
|
||||
|
||||
from .conftest import login
|
||||
|
||||
|
||||
async def test_same_key_returns_first_result(client: AsyncClient) -> None:
|
||||
user = await login(client)
|
||||
headers = {**user["headers"], "Idempotency-Key": "k-1"}
|
||||
payload = {"name": "幂等工作区"}
|
||||
|
||||
first = await client.post("/api/v1/workspaces", json=payload, headers=headers)
|
||||
second = await client.post("/api/v1/workspaces", json=payload, headers=headers)
|
||||
|
||||
assert first.status_code == 200
|
||||
assert first.json()["data"]["id"] == second.json()["data"]["id"]
|
||||
assert second.headers.get("X-Idempotent-Replay") == "1"
|
||||
|
||||
listed = await client.get("/api/v1/workspaces", headers=user["headers"])
|
||||
assert len(listed.json()["data"]["items"]) == 1
|
||||
|
||||
|
||||
async def test_different_keys_create_different_workspaces(client: AsyncClient) -> None:
|
||||
user = await login(client)
|
||||
a = await client.post(
|
||||
"/api/v1/workspaces",
|
||||
json={"name": "一号"},
|
||||
headers={**user["headers"], "Idempotency-Key": "k-a"},
|
||||
)
|
||||
b = await client.post(
|
||||
"/api/v1/workspaces",
|
||||
json={"name": "二号"},
|
||||
headers={**user["headers"], "Idempotency-Key": "k-b"},
|
||||
)
|
||||
assert a.json()["data"]["id"] != b.json()["data"]["id"]
|
||||
|
||||
|
||||
async def test_errors_are_not_cached(client: AsyncClient) -> None:
|
||||
user = await login(client)
|
||||
headers = {**user["headers"], "Idempotency-Key": "k-err"}
|
||||
bad = await client.post("/api/v1/workspaces", json={"name": ""}, headers=headers)
|
||||
assert bad.status_code == 422
|
||||
good = await client.post("/api/v1/workspaces", json={"name": "修好了"}, headers=headers)
|
||||
assert good.status_code == 200
|
||||
assert "X-Idempotent-Replay" not in good.headers
|
||||
|
||||
|
||||
async def test_keys_are_scoped_per_user(client: AsyncClient) -> None:
|
||||
alice = await login(client)
|
||||
bob = await login(client)
|
||||
a = await client.post(
|
||||
"/api/v1/workspaces",
|
||||
json={"name": "Alice 的"},
|
||||
headers={**alice["headers"], "Idempotency-Key": "shared"},
|
||||
)
|
||||
b = await client.post(
|
||||
"/api/v1/workspaces",
|
||||
json={"name": "Bob 的"},
|
||||
headers={**bob["headers"], "Idempotency-Key": "shared"},
|
||||
)
|
||||
assert a.json()["data"]["id"] != b.json()["data"]["id"]
|
||||
@@ -0,0 +1,122 @@
|
||||
"""跨租户隔离 —— 本仓库**最重要的测试**(AGENTS.md 2.5)。
|
||||
|
||||
三层验证,任何一层失效都算事故:
|
||||
1. 接口层:A 租户的令牌打不到 B 租户的资源。
|
||||
2. 数据层:应用角色连库、绑定 A 的上下文,物理上看不到 B 的行(RLS 生效)。
|
||||
3. 配置层:RLS 确实 ENABLE,且应用角色不是 superuser / BYPASSRLS(否则 RLS 被跳过)。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
|
||||
import pytest
|
||||
from httpx import AsyncClient
|
||||
from sqlalchemy import text
|
||||
|
||||
from app.db import SessionFactory, bind_tenant, current_app_role
|
||||
from app.models import TENANT_TABLES
|
||||
|
||||
from .conftest import create_workspace, login, unwrap
|
||||
|
||||
|
||||
async def _two_tenants(client: AsyncClient):
|
||||
alice = await login(client, "13900000001")
|
||||
bob = await login(client, "13900000002")
|
||||
ws_alice = await create_workspace(client, alice, "Alice 的团队")
|
||||
ws_bob = await create_workspace(client, bob, "Bob 的团队")
|
||||
return alice, bob, ws_alice, ws_bob
|
||||
|
||||
|
||||
async def test_cannot_read_other_workspace(client: AsyncClient) -> None:
|
||||
alice, _bob, _ws_a, ws_b = await _two_tenants(client)
|
||||
resp = await client.get(f"/api/v1/workspaces/{ws_b['id']}", headers=alice["headers"])
|
||||
assert resp.status_code == 403
|
||||
assert resp.json()["code"] == 3003
|
||||
|
||||
|
||||
async def test_cannot_list_other_workspace_members(client: AsyncClient) -> None:
|
||||
alice, _bob, _ws_a, ws_b = await _two_tenants(client)
|
||||
resp = await client.get(
|
||||
f"/api/v1/workspaces/{ws_b['id']}/members", headers=alice["headers"]
|
||||
)
|
||||
assert resp.status_code == 403
|
||||
|
||||
|
||||
async def test_cannot_invite_into_other_workspace(client: AsyncClient) -> None:
|
||||
alice, _bob, _ws_a, ws_b = await _two_tenants(client)
|
||||
resp = await client.post(
|
||||
f"/api/v1/workspaces/{ws_b['id']}/invitations",
|
||||
json={"role": "member"},
|
||||
headers=alice["headers"],
|
||||
)
|
||||
assert resp.status_code == 403
|
||||
|
||||
|
||||
async def test_my_workspace_list_is_scoped(client: AsyncClient) -> None:
|
||||
alice, _bob, ws_a, ws_b = await _two_tenants(client)
|
||||
mine = unwrap(await client.get("/api/v1/workspaces", headers=alice["headers"]))
|
||||
ids = [w["id"] for w in mine["items"]]
|
||||
assert ids == [ws_a["id"]]
|
||||
assert ws_b["id"] not in ids
|
||||
|
||||
|
||||
async def test_rls_hides_rows_at_database_level(client: AsyncClient) -> None:
|
||||
_alice, _bob, ws_a, ws_b = await _two_tenants(client)
|
||||
ws_a_id = uuid.UUID(ws_a["id"])
|
||||
ws_b_id = uuid.UUID(ws_b["id"])
|
||||
|
||||
async with SessionFactory() as session:
|
||||
async with session.begin():
|
||||
await bind_tenant(session, ws_a_id, None)
|
||||
visible = (
|
||||
await session.execute(text("SELECT id FROM workspace"))
|
||||
).scalars().all()
|
||||
member_ids = (
|
||||
await session.execute(text("SELECT workspace_id FROM member"))
|
||||
).scalars().all()
|
||||
|
||||
assert [str(v) for v in visible] == [ws_a["id"]]
|
||||
assert set(member_ids) == {ws_a_id}
|
||||
assert ws_b_id not in set(member_ids)
|
||||
|
||||
|
||||
async def test_rls_without_context_returns_nothing(client: AsyncClient) -> None:
|
||||
await _two_tenants(client)
|
||||
async with SessionFactory() as session:
|
||||
async with session.begin():
|
||||
count = (await session.execute(text("SELECT count(*) FROM workspace"))).scalar_one()
|
||||
assert count == 0, "未绑定租户上下文时不应看到任何行"
|
||||
|
||||
|
||||
async def test_rls_enabled_on_every_tenant_table(client: AsyncClient) -> None:
|
||||
async with SessionFactory() as session:
|
||||
rows = (
|
||||
await session.execute(
|
||||
text(
|
||||
"SELECT relname, relrowsecurity FROM pg_class "
|
||||
"WHERE relname = ANY(:names) AND relkind = 'r'"
|
||||
),
|
||||
{"names": list(TENANT_TABLES)},
|
||||
)
|
||||
).all()
|
||||
status = {name: enabled for name, enabled in rows}
|
||||
assert set(status) == set(TENANT_TABLES), "有租户表没建出来"
|
||||
assert all(status.values()), f"未启用 RLS 的表:{[k for k, v in status.items() if not v]}"
|
||||
|
||||
|
||||
async def test_app_role_cannot_bypass_rls(client: AsyncClient) -> None:
|
||||
async with SessionFactory() as session:
|
||||
async with session.begin():
|
||||
role = await current_app_role(session)
|
||||
assert role["is_super"] is False, "应用角色绝不能是 superuser,否则 RLS 被跳过"
|
||||
assert role["bypass_rls"] is False, "应用角色不能带 BYPASSRLS"
|
||||
assert role["read_all"] is False
|
||||
|
||||
|
||||
async def test_app_role_has_no_ddl(client: AsyncClient) -> None:
|
||||
"""应用角色不能建表——否则一次 SQL 注入就能改结构。"""
|
||||
async with SessionFactory() as session:
|
||||
async with session.begin():
|
||||
with pytest.raises(Exception):
|
||||
await session.execute(text("CREATE TABLE should_not_exist (id int)"))
|
||||
@@ -0,0 +1,175 @@
|
||||
"""工作区 / 成员 / 邀请。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from httpx import AsyncClient
|
||||
|
||||
from .conftest import auth, create_workspace, login, unwrap
|
||||
|
||||
|
||||
async def test_create_workspace_makes_creator_owner(client: AsyncClient) -> None:
|
||||
user = await login(client)
|
||||
ws = await create_workspace(client, user, "达道科技")
|
||||
assert ws["role"] == "owner"
|
||||
assert ws["plan"] == "free"
|
||||
assert ws["slug"].startswith("ws-")
|
||||
|
||||
|
||||
async def test_workspace_appears_in_my_list_and_me(client: AsyncClient) -> None:
|
||||
user = await login(client)
|
||||
ws = await create_workspace(client, user, "个人工作区")
|
||||
|
||||
listed = unwrap(await client.get("/api/v1/workspaces", headers=user["headers"]))
|
||||
assert [w["id"] for w in listed["items"]] == [ws["id"]]
|
||||
|
||||
me = unwrap(await client.get("/api/v1/auth/me", headers=user["headers"]))
|
||||
assert me["workspaces"][0]["role"] == "owner"
|
||||
|
||||
|
||||
async def test_members_list_contains_owner_with_masked_phone(client: AsyncClient) -> None:
|
||||
user = await login(client)
|
||||
ws = await create_workspace(client, user, "团队A")
|
||||
page = unwrap(
|
||||
await client.get(f"/api/v1/workspaces/{ws['id']}/members", headers=user["headers"])
|
||||
)
|
||||
assert page["has_more"] is False
|
||||
member = page["items"][0]
|
||||
assert member["role"] == "owner"
|
||||
assert "****" in member["phone"]
|
||||
|
||||
|
||||
async def test_invite_and_join_flow(client: AsyncClient) -> None:
|
||||
owner = await login(client)
|
||||
guest = await login(client)
|
||||
ws = await create_workspace(client, owner, "协作空间")
|
||||
|
||||
invite = unwrap(
|
||||
await client.post(
|
||||
f"/api/v1/workspaces/{ws['id']}/invitations",
|
||||
json={"role": "member", "expires_hours": 24},
|
||||
headers=owner["headers"],
|
||||
)
|
||||
)
|
||||
assert len(invite["code"]) == 8
|
||||
|
||||
joined = unwrap(
|
||||
await client.post(
|
||||
f"/api/v1/workspaces/{ws['id']}/join",
|
||||
json={"code": invite["code"].lower()}, # 邀请码大小写不敏感
|
||||
headers=guest["headers"],
|
||||
)
|
||||
)
|
||||
assert joined["role"] == "member"
|
||||
|
||||
page = unwrap(
|
||||
await client.get(f"/api/v1/workspaces/{ws['id']}/members", headers=guest["headers"])
|
||||
)
|
||||
assert {m["role"] for m in page["items"]} == {"owner", "member"}
|
||||
|
||||
|
||||
async def test_invite_code_single_use(client: AsyncClient) -> None:
|
||||
owner = await login(client)
|
||||
second = await login(client)
|
||||
third = await login(client)
|
||||
ws = await create_workspace(client, owner, "限量空间")
|
||||
invite = unwrap(
|
||||
await client.post(
|
||||
f"/api/v1/workspaces/{ws['id']}/invitations",
|
||||
json={"role": "member"},
|
||||
headers=owner["headers"],
|
||||
)
|
||||
)
|
||||
await client.post(
|
||||
f"/api/v1/workspaces/{ws['id']}/join",
|
||||
json={"code": invite["code"]},
|
||||
headers=second["headers"],
|
||||
)
|
||||
resp = await client.post(
|
||||
f"/api/v1/workspaces/{ws['id']}/join",
|
||||
json={"code": invite["code"]},
|
||||
headers=third["headers"],
|
||||
)
|
||||
assert resp.status_code == 400
|
||||
assert resp.json()["code"] == 3010
|
||||
|
||||
|
||||
async def test_bad_invite_code_rejected(client: AsyncClient) -> None:
|
||||
owner = await login(client)
|
||||
guest = await login(client)
|
||||
ws = await create_workspace(client, owner, "封闭空间")
|
||||
resp = await client.post(
|
||||
f"/api/v1/workspaces/{ws['id']}/join",
|
||||
json={"code": "ZZZZZZZZ"},
|
||||
headers=guest["headers"],
|
||||
)
|
||||
assert resp.json()["code"] == 3010
|
||||
|
||||
|
||||
async def test_plain_member_cannot_invite(client: AsyncClient) -> None:
|
||||
owner = await login(client)
|
||||
member = await login(client)
|
||||
ws = await create_workspace(client, owner, "权限空间")
|
||||
invite = unwrap(
|
||||
await client.post(
|
||||
f"/api/v1/workspaces/{ws['id']}/invitations",
|
||||
json={"role": "member"},
|
||||
headers=owner["headers"],
|
||||
)
|
||||
)
|
||||
await client.post(
|
||||
f"/api/v1/workspaces/{ws['id']}/join",
|
||||
json={"code": invite["code"]},
|
||||
headers=member["headers"],
|
||||
)
|
||||
|
||||
resp = await client.post(
|
||||
f"/api/v1/workspaces/{ws['id']}/invitations",
|
||||
json={"role": "member"},
|
||||
headers=member["headers"],
|
||||
)
|
||||
assert resp.status_code == 403
|
||||
assert resp.json()["code"] == 3004
|
||||
|
||||
|
||||
async def test_only_owner_can_invite_admin(client: AsyncClient) -> None:
|
||||
owner = await login(client)
|
||||
admin = await login(client)
|
||||
ws = await create_workspace(client, owner, "治理空间")
|
||||
|
||||
first = unwrap(
|
||||
await client.post(
|
||||
f"/api/v1/workspaces/{ws['id']}/invitations",
|
||||
json={"role": "admin"},
|
||||
headers=owner["headers"],
|
||||
)
|
||||
)
|
||||
await client.post(
|
||||
f"/api/v1/workspaces/{ws['id']}/join",
|
||||
json={"code": first["code"]},
|
||||
headers=admin["headers"],
|
||||
)
|
||||
|
||||
resp = await client.post(
|
||||
f"/api/v1/workspaces/{ws['id']}/invitations",
|
||||
json={"role": "admin"},
|
||||
headers=admin["headers"],
|
||||
)
|
||||
assert resp.json()["code"] == 3004
|
||||
|
||||
|
||||
async def test_duplicate_slug_conflict(client: AsyncClient) -> None:
|
||||
user = await login(client)
|
||||
await client.post(
|
||||
"/api/v1/workspaces", json={"name": "占位", "slug": "taken"}, headers=user["headers"]
|
||||
)
|
||||
resp = await client.post(
|
||||
"/api/v1/workspaces", json={"name": "抢占", "slug": "taken"}, headers=user["headers"]
|
||||
)
|
||||
assert resp.status_code == 409
|
||||
assert resp.json()["code"] == 1009
|
||||
|
||||
|
||||
async def test_requires_auth(client: AsyncClient) -> None:
|
||||
resp = await client.get("/api/v1/workspaces")
|
||||
assert resp.status_code == 401
|
||||
assert auth("bad.token.here") # 保证 helper 可用
|
||||
Reference in New Issue
Block a user