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
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 可用