feat(invite): 添加邀请码功能模块

This commit is contained in:
qzl
2026-04-07 18:43:49 +08:00
parent b22673ce49
commit c121c1092f
4 changed files with 962 additions and 1 deletions
@@ -0,0 +1,329 @@
"""add invite_codes table and profiles referred_by
Revision ID: 20260407_0002
Revises: 20260407_0001
Create Date: 2026-04-07 00:00:00
"""
from typing import Sequence, Union
from alembic import op
revision: str = "20260407_0002"
down_revision: Union[str, Sequence[str], None] = "20260407_0001"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.execute(
"""
CREATE TABLE invite_codes (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
code VARCHAR(6) NOT NULL UNIQUE CHECK (code ~ '^[ABCDEFGHJKMNPQRSTUVWXYZ23456789]{6}$'),
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),
max_uses INTEGER CHECK (max_uses IS NULL OR max_uses >= 1),
expires_at TIMESTAMPTZ NULL,
reward_config JSONB NOT NULL DEFAULT '{}',
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
)
"""
)
op.execute("CREATE INDEX ix_invite_codes_owner_id ON invite_codes(owner_id)")
op.execute(
"CREATE INDEX ix_invite_codes_code ON invite_codes(code) WHERE status = 'active'"
)
op.execute("ALTER TABLE invite_codes ENABLE ROW LEVEL SECURITY")
op.execute("DROP POLICY IF EXISTS invite_codes_all_denied ON invite_codes")
op.execute(
"CREATE POLICY invite_codes_all_denied ON invite_codes FOR ALL USING (false)"
)
op.execute(
"""
ALTER TABLE profiles ADD COLUMN referred_by UUID REFERENCES profiles(id) ON DELETE SET NULL
"""
)
op.execute("CREATE INDEX ix_profiles_referred_by ON profiles(referred_by)")
op.execute(
"""
CREATE OR REPLACE FUNCTION public.generate_invite_code()
RETURNS TEXT
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = ''
AS $$
DECLARE
chars TEXT := 'ABCDEFGHJKMNPQRSTUVWXYZ23456789';
result TEXT := '';
i INT;
BEGIN
FOR i IN 1..6 LOOP
result := result || substr(chars, floor(random() * length(chars) + 1)::int, 1);
END LOOP;
RETURN result;
END;
$$;
"""
)
op.execute(
"""
CREATE OR REPLACE FUNCTION public.initialize_profile_and_invite_code_on_signup()
RETURNS trigger
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = public
AS $$
DECLARE
v_username text;
v_ledger_id uuid;
v_event_id text;
v_invite_code text;
v_referrer_id uuid;
v_attempts int := 0;
invite_code_value text;
BEGIN
v_username := 'user_' || substring(md5(new.id::text || clock_timestamp()::text || random()::text) from 1 for 6);
v_ledger_id := md5(new.id::text || 'ledger' || clock_timestamp()::text || random()::text)::uuid;
v_event_id := 'register:' || new.id::text;
INSERT INTO public.profiles (id, username, avatar_url, bio, settings)
VALUES (
new.id,
v_username,
null,
null,
jsonb_build_object(
'version', 1,
'preferences', jsonb_build_object(
'interface_language', 'zh-CN',
'ai_language', 'zh-CN',
'timezone', 'Asia/Shanghai',
'country', 'CN'
),
'privacy', jsonb_build_object('profile_visibility', 'public'),
'notification', jsonb_build_object(
'allow_notifications', true,
'allow_vibration', true
)
)
)
ON CONFLICT (id) DO NOTHING;
INSERT INTO public.user_points (
user_id,
balance,
frozen_balance,
lifetime_earned,
lifetime_spent,
version
)
VALUES (new.id, 100, 0, 100, 0, 0)
ON CONFLICT (user_id) DO NOTHING;
INSERT INTO public.points_ledger (
id,
user_id,
direction,
amount,
balance_after,
change_type,
biz_type,
biz_id,
event_id,
operator_id,
metadata
)
VALUES (
v_ledger_id,
new.id,
1,
100,
100,
'register',
null,
null,
v_event_id,
null,
jsonb_build_object(
'schema_version', 1,
'reason_code', 'REGISTER_WELCOME',
'operator_type', 'system',
'run_id', v_event_id,
'request_id', null,
'ext', jsonb_build_object('source', 'auth_signup')
)
)
ON CONFLICT (user_id, event_id) DO NOTHING;
LOOP
BEGIN
v_invite_code := public.generate_invite_code();
INSERT INTO public.invite_codes (code, owner_id, status, used_count, max_uses, expires_at, reward_config)
VALUES (
v_invite_code,
new.id,
'active',
0,
NULL,
NULL,
'{}'::jsonb
);
EXIT;
EXCEPTION WHEN unique_violation THEN
v_attempts := v_attempts + 1;
IF v_attempts >= 100 THEN
RAISE EXCEPTION 'Failed to generate unique invite code after 100 attempts';
END IF;
END;
END LOOP;
invite_code_value := new.raw_user_meta_data ->> 'invite_code';
IF invite_code_value IS NOT NULL AND length(invite_code_value) = 6 THEN
invite_code_value := upper(invite_code_value);
IF invite_code_value ~ '^[ABCDEFGHJKMNPQRSTUVWXYZ23456789]{6}$' THEN
UPDATE public.invite_codes
SET used_count = used_count + 1
WHERE code = invite_code_value
AND status = 'active'
AND (max_uses IS NULL OR used_count < max_uses)
AND (expires_at IS NULL OR expires_at > NOW())
RETURNING owner_id INTO v_referrer_id;
IF v_referrer_id IS NOT NULL THEN
UPDATE public.profiles
SET referred_by = v_referrer_id
WHERE id = new.id;
END IF;
END IF;
END IF;
RETURN NEW;
END;
$$;
"""
)
op.execute("DROP TRIGGER IF EXISTS on_auth_user_created ON auth.users")
op.execute(
"""
CREATE TRIGGER on_auth_user_created
AFTER INSERT ON auth.users
FOR EACH ROW
EXECUTE FUNCTION public.initialize_profile_and_invite_code_on_signup()
"""
)
def downgrade() -> None:
op.execute("DROP TRIGGER IF EXISTS on_auth_user_created ON auth.users")
op.execute(
"""
CREATE OR REPLACE FUNCTION public.initialize_profile_and_points_on_signup()
RETURNS trigger
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = public
AS $$
DECLARE
v_username text;
v_ledger_id uuid;
v_event_id text;
BEGIN
v_username := 'user_' || substring(md5(new.id::text || clock_timestamp()::text || random()::text) from 1 for 6);
v_ledger_id := md5(new.id::text || 'ledger' || clock_timestamp()::text || random()::text)::uuid;
v_event_id := 'register:' || new.id::text;
INSERT INTO public.profiles (id, username, avatar_url, bio, settings)
VALUES (
new.id,
v_username,
null,
null,
jsonb_build_object(
'version', 1,
'preferences', jsonb_build_object(
'interface_language', 'zh-CN',
'ai_language', 'zh-CN',
'timezone', 'Asia/Shanghai',
'country', 'CN'
),
'privacy', jsonb_build_object('profile_visibility', 'public'),
'notification', jsonb_build_object('push_enabled', true)
)
)
ON CONFLICT (id) DO NOTHING;
INSERT INTO public.user_points (
user_id,
balance,
frozen_balance,
lifetime_earned,
lifetime_spent,
version
)
VALUES (new.id, 100, 0, 100, 0, 0)
ON CONFLICT (user_id) DO NOTHING;
INSERT INTO public.points_ledger (
id,
user_id,
direction,
amount,
balance_after,
change_type,
biz_type,
biz_id,
event_id,
operator_id,
metadata
)
VALUES (
v_ledger_id,
new.id,
1,
100,
100,
'register',
null,
null,
v_event_id,
null,
jsonb_build_object(
'schema_version', 1,
'reason_code', 'REGISTER_WELCOME',
'operator_type', 'system',
'run_id', v_event_id,
'request_id', null,
'ext', jsonb_build_object('source', 'auth_signup')
)
)
ON CONFLICT (user_id, event_id) DO NOTHING;
RETURN NEW;
END;
$$;
"""
)
op.execute(
"CREATE TRIGGER on_auth_user_created AFTER INSERT ON auth.users FOR EACH ROW EXECUTE FUNCTION public.initialize_profile_and_points_on_signup()"
)
op.execute("DROP FUNCTION IF EXISTS public.generate_invite_code()")
op.execute("DROP INDEX IF EXISTS ix_profiles_referred_by")
op.execute("ALTER TABLE profiles DROP COLUMN IF EXISTS referred_by")
op.execute("DROP POLICY IF EXISTS invite_codes_all_denied ON invite_codes")
op.execute("ALTER TABLE invite_codes DISABLE ROW LEVEL SECURITY")
op.execute("DROP INDEX IF EXISTS ix_invite_codes_code")
op.execute("DROP INDEX IF EXISTS ix_invite_codes_owner_id")
op.execute("DROP TABLE IF EXISTS invite_codes")
+71
View File
@@ -0,0 +1,71 @@
from __future__ import annotations
import uuid
from datetime import datetime
from sqlalchemy import CheckConstraint, DateTime, ForeignKey, Integer, String
from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy.orm import Mapped, mapped_column
from core.db.base import Base, TimestampMixin
from core.db.types import json_jsonb
from schemas.enums import InviteCodeStatus
__all__ = ["InviteCode", "InviteCodeStatus"]
class InviteCode(TimestampMixin, Base):
__tablename__: str = "invite_codes"
__table_args__ = (
CheckConstraint(
"status IN ('active', 'disabled', 'expired')",
name="invite_codes_status_check",
),
CheckConstraint("used_count >= 0", name="invite_codes_used_count_check"),
CheckConstraint(
"max_uses IS NULL OR max_uses >= 1",
name="invite_codes_max_uses_check",
),
{"extend_existing": True},
)
id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True),
primary_key=True,
default=uuid.uuid4,
)
code: Mapped[str] = mapped_column(
String(6),
nullable=False,
unique=True,
index=True,
)
owner_id: Mapped[uuid.UUID | None] = mapped_column(
UUID(as_uuid=True),
ForeignKey("profiles.id", ondelete="SET NULL"),
nullable=True,
index=True,
)
status: Mapped[str] = mapped_column(
String(20),
nullable=False,
default=InviteCodeStatus.ACTIVE.value,
)
used_count: Mapped[int] = mapped_column(
Integer,
nullable=False,
default=0,
)
max_uses: Mapped[int | None] = mapped_column(
Integer,
nullable=True,
)
expires_at: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True),
nullable=True,
)
reward_config: Mapped[dict[str, object]] = mapped_column(
json_jsonb,
nullable=False,
server_default="{}",
)
+41 -1
View File
@@ -1,11 +1,51 @@
from __future__ import annotations
from datetime import datetime
from typing import ClassVar
from uuid import UUID
from pydantic import BaseModel, ConfigDict
from pydantic import BaseModel, ConfigDict, Field
from schemas.enums import InviteCodeStatus
class InviteCodeRewardConfig(BaseModel):
model_config: ClassVar[ConfigDict] = ConfigDict(extra="allow")
pass
class InviteCodeBase(BaseModel):
code: str = Field(
...,
min_length=6,
max_length=6,
pattern=r"^[ABCDEFGHJKMNPQRSTUVWXYZ23456789]{6}$",
)
status: InviteCodeStatus = InviteCodeStatus.ACTIVE
used_count: int = Field(default=0, ge=0)
max_uses: int | None = Field(default=None, ge=1)
expires_at: datetime | None = None
reward_config: InviteCodeRewardConfig = Field(
default_factory=InviteCodeRewardConfig
)
class InviteCodeCreate(InviteCodeBase):
owner_id: UUID | None = None
class InviteCodeUpdate(BaseModel):
status: InviteCodeStatus | None = None
max_uses: int | None = Field(default=None, ge=1)
expires_at: datetime | None = None
reward_config: InviteCodeRewardConfig | None = None
class InviteCodeRead(InviteCodeBase):
model_config: ClassVar[ConfigDict] = ConfigDict(from_attributes=True)
id: UUID
owner_id: UUID | None
created_at: datetime
updated_at: datetime