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:
Steven
2026-10-28 02:38:53 +08:00
commit 0c53aa7062
72 changed files with 5320 additions and 0 deletions
+18
View File
@@ -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
+38
View File
@@ -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
+66
View File
@@ -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()
+27
View File
@@ -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"}
+197
View File
@@ -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();")
View File
View File
+142
View File
@@ -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
],
}
)
+67
View File
@@ -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]
+240
View File
@@ -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)
+35
View File
@@ -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()
+93
View File
@@ -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
+94
View File
@@ -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
+114
View File
@@ -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"))]
+84
View File
@@ -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)
+80
View File
@@ -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)
+132
View File
@@ -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,
}
+142
View File
@@ -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,
}
+135
View File
@@ -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")
+77
View File
@@ -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)
+80
View File
@@ -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)
+111
View File
@@ -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}"
+22
View File
@@ -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"]
+15
View File
@@ -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
View File
+98
View File
@@ -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"]))
+88
View File
@@ -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"}
+64
View File
@@ -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"]
+122
View File
@@ -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)"))
+175
View File
@@ -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 可用