diff --git a/backend/alembic/versions/20260227_0006_invite_codes_and_profile_referral.py b/backend/alembic/versions/20260227_0006_invite_codes_and_profile_referral.py index 5867909..08c6180 100644 --- a/backend/alembic/versions/20260227_0006_invite_codes_and_profile_referral.py +++ b/backend/alembic/versions/20260227_0006_invite_codes_and_profile_referral.py @@ -20,7 +20,7 @@ def upgrade() -> None: """ CREATE TABLE invite_codes ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - code VARCHAR(8) NOT NULL UNIQUE, + code VARCHAR(8) NOT NULL UNIQUE CHECK (code ~ '^[ABCDEFGHJKMNPQRSTUVWXYZ23456789]{8}$'), owner_id UUID REFERENCES profiles(id) ON DELETE SET NULL, status VARCHAR(20) NOT NULL DEFAULT 'active' CHECK (status IN ('active', 'disabled', 'expired')), used_count INTEGER NOT NULL DEFAULT 0 CHECK (used_count >= 0), @@ -153,7 +153,6 @@ def upgrade() -> None: def downgrade() -> None: - op.execute("DROP FUNCTION IF EXISTS public.create_profile_for_new_user()") op.execute( """ CREATE OR REPLACE FUNCTION public.create_profile_for_new_user() diff --git a/backend/tests/integration/test_auth_routes.py b/backend/tests/integration/test_auth_routes.py index 72a21d6..b42b6b0 100644 --- a/backend/tests/integration/test_auth_routes.py +++ b/backend/tests/integration/test_auth_routes.py @@ -792,3 +792,96 @@ def test_password_reset_confirm_weak_password_returns_422() -> None: assert response.headers["content-type"].startswith("application/problem+json") finally: app.dependency_overrides = {} + + +class TestInviteCodeSignup: + def test_signup_with_valid_invite_code_returns_202(self) -> None: + user = AuthUser(id="user-1", email="user@example.com") + token_response = SessionResponse( + access_token="access", + refresh_token="refresh", + expires_in=3600, + token_type="bearer", + user=user, + ) + app.dependency_overrides[get_auth_service] = _override_auth_service( + FakeAuthService(token_response) + ) + + client = TestClient(app) + try: + response = client.post( + "/api/v1/auth/verifications", + json={ + "username": "demo", + "email": "user@example.com", + "password": "secret123", + "invite_code": "A2B3C4D5", + }, + ) + assert response.status_code == 202 + assert response.json() == {"email": "user@example.com"} + finally: + app.dependency_overrides = {} + + def test_signup_with_invalid_invite_code_length_returns_422(self) -> None: + user = AuthUser(id="user-1", email="user@example.com") + token_response = SessionResponse( + access_token="access", + refresh_token="refresh", + expires_in=3600, + token_type="bearer", + user=user, + ) + app.dependency_overrides[get_auth_service] = _override_auth_service( + FakeAuthService(token_response) + ) + + client = TestClient(app) + try: + response = client.post( + "/api/v1/auth/verifications", + json={ + "username": "demo", + "email": "user@example.com", + "password": "secret123", + "invite_code": "ABC123", + }, + ) + assert response.status_code == 422 + assert response.headers["content-type"].startswith( + "application/problem+json" + ) + finally: + app.dependency_overrides = {} + + def test_signup_with_invalid_invite_code_chars_returns_422(self) -> None: + user = AuthUser(id="user-1", email="user@example.com") + token_response = SessionResponse( + access_token="access", + refresh_token="refresh", + expires_in=3600, + token_type="bearer", + user=user, + ) + app.dependency_overrides[get_auth_service] = _override_auth_service( + FakeAuthService(token_response) + ) + + client = TestClient(app) + try: + response = client.post( + "/api/v1/auth/verifications", + json={ + "username": "demo", + "email": "user@example.com", + "password": "secret123", + "invite_code": "ABCD1234", + }, + ) + assert response.status_code == 422 + assert response.headers["content-type"].startswith( + "application/problem+json" + ) + finally: + app.dependency_overrides = {} diff --git a/docs/runtime/runtime-route.md b/docs/runtime/runtime-route.md index 066e27d..be48a8f 100644 --- a/docs/runtime/runtime-route.md +++ b/docs/runtime/runtime-route.md @@ -20,7 +20,8 @@ "username": "string (3-30 chars)", "email": "string (email)", "password": "string (min 6 chars)", - "redirect_to": "string? (optional)" + "redirect_to": "string? (optional)", + "invite_code": "string? (8 chars, 排除易混淆字符 0/1/I/L/O)" } ``` @@ -31,6 +32,12 @@ } ``` +**邀请码说明:** +- 可选字段,不填则注册不受影响 +- 格式:8 位字母数字组合,排除易混淆字符 (0, 1, I, L, O) +- 注册时传入有效邀请码会建立邀请关系并增加邀请码使用次数 +- 无效邀请码(不存在/已禁用/已过期/已达上限)不会阻断注册成功 + **Errors:** - 422: 请求参数无效 - 429: 请求过于频繁 diff --git a/docs/runtime/runtime-runbook.md b/docs/runtime/runtime-runbook.md index a0deb93..1cca722 100644 --- a/docs/runtime/runtime-runbook.md +++ b/docs/runtime/runtime-runbook.md @@ -245,3 +245,4 @@ docker compose --env-file .env -f infra/docker/docker-compose.yml up -d --force- | 2026-02-25 | 重构为运维分层手册:Bootstrap Gate、分层验证、故障与回滚流程 | | 2026-02-25 | 新增配置漂移故障条目:修复 Auth 邮件模板失效与 signup 超时场景 | | 2026-02-27 | 用户搜索支持邮箱精确匹配:query 含 @ 符号时走 auth.users → profiles 两步查询 | +| 2026-02-28 | 邀请码功能:新增 invite_codes 表、profiles.referred_by,注册时可选填邀请码并记录邀请关系 |