From 6e69f599c4ddbce07c212fbcd5a83a492714286c Mon Sep 17 00:00:00 2001 From: xiaocheny214 <187097481+xiaocheny214@users.noreply.github.com> Date: Mon, 17 Aug 2026 18:22:40 +0800 Subject: [PATCH 01/10] =?UTF-8?q?feat(auth):=20=E7=94=A8=E9=82=80=E8=AF=B7?= =?UTF-8?q?=E7=A0=81=E9=87=8D=E6=96=B0=E5=BC=80=E6=94=BE=E6=B3=A8=E5=86=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 公开注册仍关闭自动建号,改为邀请码门槛,并补齐查看、轮换和补填邀请码接口。 Closes #355 --- .../app/src/windup_app/bootstrap/app.py | 3 +- .../src/windup_app/server/quota/interface.py | 45 ++-- .../app/src/windup_app/server/quota/model.py | 93 +++++++-- .../src/windup_app/server/quota/service.py | 195 ++++++++++++++++-- .../src/windup_app/server/user/interface.py | 2 +- .../app/src/windup_app/server/user/model.py | 1 + .../app/src/windup_app/server/user/service.py | 54 +++-- .../app/src/windup_app/web/api/auth.py | 78 +++++-- .../app/src/windup_app/web/api/quota.py | 86 +++++--- backend/tests/conftest.py | 51 ++++- .../tests/test_auth_registration_closed.py | 27 ++- backend/tests/test_quota.py | 190 ++++++++++++++--- backend/tests/test_user_service.py | 155 +++++++++++--- frontend/src/app/layout/app-header.test.tsx | 22 +- frontend/src/app/layout/app-header.tsx | 5 +- frontend/src/entities/index.ts | 1 + frontend/src/entities/quota/api.test.ts | 54 ++++- frontend/src/entities/quota/api.ts | 34 +++ frontend/src/entities/quota/index.ts | 1 + frontend/src/entities/quota/types.ts | 11 + frontend/src/entities/user/api.test.ts | 4 + frontend/src/entities/user/api.ts | 1 + frontend/src/entities/user/index.ts | 1 + .../src/features/account-panel/index.test.tsx | 57 ++--- frontend/src/features/account-panel/index.tsx | 70 ++++--- .../src/features/auth-session/index.test.tsx | 1 + frontend/src/features/quota/index.test.ts | 14 ++ frontend/src/pages/account/index.test.tsx | 58 ++++++ frontend/src/pages/account/index.tsx | 147 ++++++++++++- frontend/src/pages/landing/index.test.tsx | 12 +- .../src/pages/landing/marketing-header.tsx | 24 --- openapi.json | 189 ++++++++++++++++- 32 files changed, 1379 insertions(+), 307 deletions(-) diff --git a/backend/packages/app/src/windup_app/bootstrap/app.py b/backend/packages/app/src/windup_app/bootstrap/app.py index fcfa903a..8ba5c1ef 100644 --- a/backend/packages/app/src/windup_app/bootstrap/app.py +++ b/backend/packages/app/src/windup_app/bootstrap/app.py @@ -20,8 +20,7 @@ from windup_app.server.character.service import service as character_service from windup_app.server.orchestrator.dispatcher import GenerationDispatcher from windup_app.server.project.model import Project # noqa: F401 -from windup_app.server.quota.model import CreditAccount, CreditTransaction # noqa: F401 -# InviteCode, InviteRecord, TokenUsage 暂不实现 +from windup_app.server.quota.model import CreditAccount, CreditTransaction, InviteCode, InviteRecord # noqa: F401 from windup_app.server.user.model import User # noqa: F401 from windup_app.server.workflow_run.model import WorkflowRun # noqa: F401 from windup_app.web.api.auth import router as auth_router diff --git a/backend/packages/app/src/windup_app/server/quota/interface.py b/backend/packages/app/src/windup_app/server/quota/interface.py index c419c466..0201c36f 100644 --- a/backend/packages/app/src/windup_app/server/quota/interface.py +++ b/backend/packages/app/src/windup_app/server/quota/interface.py @@ -10,6 +10,7 @@ from windup_app.server.quota.model import ( CreditAccountView, CreditTransactionView, + InviteCodeView, ) @@ -35,7 +36,12 @@ def reserve_credit( @abstractmethod def capture_credit( - self, session: Session, user_id: int, actual_amount: int, ref_id: str, frozen_amount: int + self, + session: Session, + user_id: int, + actual_amount: int, + ref_id: str, + frozen_amount: int, ) -> None: """预付费扣减:冻结转消耗。 @@ -65,7 +71,12 @@ def release_credit( @abstractmethod def credit( - self, session: Session, user_id: int, amount: int, reason: int, ref_id: str | None = None + self, + session: Session, + user_id: int, + amount: int, + reason: int, + ref_id: str | None = None, ) -> None: """入账:增加可用余额与累计获得。""" @@ -78,18 +89,18 @@ def list_transactions( """分页查询积分流水,返回 (列表, 总数)。""" # -- 邀请码 ----------------------------------------------------------- - # TODO 目前先不实现。 - # @abstractmethod - # def get_invite_code(self, session: Session, user_id: int) -> InviteCodeView | None: - # """获取用户当前邀请码。""" - # - # @abstractmethod - # def generate_invite_code(self, session: Session, user_id: int) -> InviteCodeView: - # """生成新邀请码(替换旧码)。""" - # - # @abstractmethod - # def redeem_invite_code(self, session: Session, user_id: int, code: str) -> None: - # """兑换邀请码,双方各得积分。 - # - # :raises BizException: 邀请码无效 / 已达上限 / 已填过码。 - # """ + + @abstractmethod + def get_invite_code(self, session: Session, user_id: int) -> InviteCodeView: + """获取用户当前邀请码;没有则生成。""" + + @abstractmethod + def generate_invite_code(self, session: Session, user_id: int) -> InviteCodeView: + """生成新邀请码(替换旧码)。""" + + @abstractmethod + def redeem_invite_code(self, session: Session, user_id: int, code: str) -> None: + """兑换邀请码,双方各得积分。 + + :raises BizException: 邀请码无效 / 已填过码 / 不能填自己的码。 + """ diff --git a/backend/packages/app/src/windup_app/server/quota/model.py b/backend/packages/app/src/windup_app/server/quota/model.py index 45e82ff4..568bfcaf 100644 --- a/backend/packages/app/src/windup_app/server/quota/model.py +++ b/backend/packages/app/src/windup_app/server/quota/model.py @@ -17,7 +17,14 @@ from dataclasses import dataclass, field from datetime import datetime, timezone -from sqlalchemy import BigInteger, DateTime, Integer, SmallInteger, String, UniqueConstraint +from sqlalchemy import ( + BigInteger, + DateTime, + Integer, + SmallInteger, + String, + UniqueConstraint, +) from sqlalchemy.orm import Mapped, mapped_column from windup_framework.db import Base @@ -107,18 +114,64 @@ class CreditTransaction(Base): ) -# -- 以下 ORM 暂不实现(枚举 / 接口已预留)---------------------------------- -# -# class InviteCode(Base): -# """邀请码。""" -# __tablename__ = "windup_invite_code" -# ... -# -# class InviteRecord(Base): -# """邀请记录。""" -# __tablename__ = "windup_invite_record" -# ... -# +class InviteCode(Base): + """用户当前邀请码。每人一行,轮换时覆盖 code。""" + + __tablename__ = "windup_invite_code" + + id: Mapped[int] = mapped_column( + BigInteger().with_variant(Integer, "sqlite"), + primary_key=True, + autoincrement=True, + ) + user_id: Mapped[int] = mapped_column( + BigInteger().with_variant(Integer, "sqlite"), + unique=True, + nullable=False, + ) + code: Mapped[str] = mapped_column(String(16), unique=True, nullable=False) + used_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0) + create_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), + nullable=False, + default=lambda: datetime.now(timezone.utc), + ) + update_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), + nullable=False, + default=lambda: datetime.now(timezone.utc), + onupdate=lambda: datetime.now(timezone.utc), + ) + + +class InviteRecord(Base): + """一次成功的邀请关系。被邀请人只能出现一次。""" + + __tablename__ = "windup_invite_record" + + id: Mapped[int] = mapped_column( + BigInteger().with_variant(Integer, "sqlite"), + primary_key=True, + autoincrement=True, + ) + inviter_id: Mapped[int] = mapped_column( + BigInteger().with_variant(Integer, "sqlite"), + nullable=False, + index=True, + ) + invitee_id: Mapped[int] = mapped_column( + BigInteger().with_variant(Integer, "sqlite"), + unique=True, + nullable=False, + ) + code: Mapped[str] = mapped_column(String(16), nullable=False) + create_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), + nullable=False, + default=lambda: datetime.now(timezone.utc), + ) + + # class TokenUsage(Base): # """Token 用量记录。""" # __tablename__ = "windup_token_usage" @@ -156,9 +209,11 @@ class CreditTransactionView: create_at: datetime = field(default_factory=lambda: datetime.now(timezone.utc)) -# -- 暂不实现 -- -# -# @dataclass -# class InviteCodeView: -# """邀请码视图。""" -# ... +@dataclass +class InviteCodeView: + """邀请码视图。""" + + code: str + used_count: int = 0 + create_at: datetime = field(default_factory=lambda: datetime.now(timezone.utc)) + update_at: datetime = field(default_factory=lambda: datetime.now(timezone.utc)) diff --git a/backend/packages/app/src/windup_app/server/quota/service.py b/backend/packages/app/src/windup_app/server/quota/service.py index 5fcb63bf..58f533f2 100644 --- a/backend/packages/app/src/windup_app/server/quota/service.py +++ b/backend/packages/app/src/windup_app/server/quota/service.py @@ -12,6 +12,7 @@ """ import logging +import secrets from sqlalchemy import func, select from sqlalchemy.orm import Session @@ -26,10 +27,35 @@ CreditAccountView, CreditTransaction, CreditTransactionView, + InviteCode, + InviteCodeView, + InviteRecord, ) +from windup_app.server.user.model import User +from windup_framework.config.quota import settings as quota_settings logger = logging.getLogger("windup.quota.service") +_INVITE_ALPHABET = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789" +_INVITE_CODE_LENGTH = 8 + + +def normalize_invite_code(code: str) -> str: + return code.strip().upper() + + +def _new_invite_code() -> str: + return "".join(secrets.choice(_INVITE_ALPHABET) for _ in range(_INVITE_CODE_LENGTH)) + + +def _to_invite_view(row: InviteCode) -> InviteCodeView: + return InviteCodeView( + code=row.code, + used_count=row.used_count, + create_at=row.create_at, + update_at=row.update_at, + ) + def _to_account_view(account: CreditAccount) -> CreditAccountView: return CreditAccountView( @@ -120,17 +146,30 @@ def reserve_credit( session.flush() self._write_txn( - session, user_id, -amount, CreditReason.FROZEN, - BillingMode.PREPAID, account.balance, ref_id, + session, + user_id, + -amount, + CreditReason.FROZEN, + BillingMode.PREPAID, + account.balance, + ref_id, ) logger.info( "[WINDUP] 积分冻结 | user_id=%s amount=%s ref_id=%s balance=%s", - user_id, amount, ref_id, account.balance, + user_id, + amount, + ref_id, + account.balance, ) def capture_credit( - self, session: Session, user_id: int, actual_amount: int, ref_id: str, frozen_amount: int + self, + session: Session, + user_id: int, + actual_amount: int, + ref_id: str, + frozen_amount: int, ) -> None: """预付费扣减:frozen -= frozen_amount, total_spent += actual_amount。 @@ -158,20 +197,34 @@ def capture_credit( # 写扣减流水 self._write_txn( - session, user_id, -actual_amount, CreditReason.CAPTURED, - BillingMode.PREPAID, account.balance, ref_id, + session, + user_id, + -actual_amount, + CreditReason.CAPTURED, + BillingMode.PREPAID, + account.balance, + ref_id, ) # 有差额退回时写退款流水(用不同 reason 区分,ref_id 加后缀去重) if refund > 0: self._write_txn( - session, user_id, refund, CreditReason.REFUND, - BillingMode.PREPAID, account.balance, f"{ref_id}:refund", + session, + user_id, + refund, + CreditReason.REFUND, + BillingMode.PREPAID, + account.balance, + f"{ref_id}:refund", ) logger.info( "[WINDUP] 积分扣减 | user_id=%s actual=%s frozen=%s refund=%s balance=%s", - user_id, actual_amount, frozen_amount, refund, account.balance, + user_id, + actual_amount, + frozen_amount, + refund, + account.balance, ) def release_credit( @@ -191,13 +244,21 @@ def release_credit( session.flush() self._write_txn( - session, user_id, amount, CreditReason.REFUND, - BillingMode.PREPAID, account.balance, f"{ref_id}:release", + session, + user_id, + amount, + CreditReason.REFUND, + BillingMode.PREPAID, + account.balance, + f"{ref_id}:release", ) logger.info( "[WINDUP] 积分解冻 | user_id=%s amount=%s ref_id=%s balance=%s", - user_id, amount, ref_id, account.balance, + user_id, + amount, + ref_id, + account.balance, ) # -- 后付费:原子扣减(暂不实现,AGENT_TOKEN / POSTPAID 枚举已预留)------ @@ -211,7 +272,12 @@ def release_credit( # -- 入账(赠送 / 奖励 / 管理员调整)---------------------------------- def credit( - self, session: Session, user_id: int, amount: int, reason: int, ref_id: str | None = None + self, + session: Session, + user_id: int, + amount: int, + reason: int, + ref_id: str | None = None, ) -> None: """入账:balance += amount, total_earned += amount。""" if amount <= 0: @@ -223,13 +289,21 @@ def credit( session.flush() self._write_txn( - session, user_id, amount, reason, - BillingMode.PREPAID, account.balance, ref_id, + session, + user_id, + amount, + reason, + BillingMode.PREPAID, + account.balance, + ref_id, ) logger.info( "[WINDUP] 积分入账 | user_id=%s amount=%s reason=%s balance=%s", - user_id, amount, reason, account.balance, + user_id, + amount, + reason, + account.balance, ) # -- 流水查询 --------------------------------------------------------- @@ -254,8 +328,93 @@ def list_transactions( return [_to_txn_view(r) for r in rows], total or 0 - # -- 邀请码(暂不实现)------------------------------------------------- - # TODO: get_invite_code / generate_invite_code / redeem_invite_code + # -- 邀请码 ----------------------------------------------------------- + + def get_invite_code(self, session: Session, user_id: int) -> InviteCodeView: + row = session.scalar(select(InviteCode).where(InviteCode.user_id == user_id)) + if row is not None: + return _to_invite_view(row) + return self.generate_invite_code(session, user_id) + + def generate_invite_code(self, session: Session, user_id: int) -> InviteCodeView: + if session.get(User, user_id) is None: + raise BizException("用户不存在", code=BizCode.NOT_FOUND) + + row = session.scalar(select(InviteCode).where(InviteCode.user_id == user_id)) + if row is None: + row = InviteCode( + user_id=user_id, code=self._allocate_invite_code(session), used_count=0 + ) + session.add(row) + else: + row.code = self._allocate_invite_code(session, exclude_id=row.id) + session.flush() + logger.info("[WINDUP] 生成邀请码 | user_id=%s code=%s", user_id, row.code) + return _to_invite_view(row) + + def _allocate_invite_code( + self, session: Session, exclude_id: int | None = None + ) -> str: + for _ in range(16): + code = _new_invite_code() + query = select(InviteCode.id).where(InviteCode.code == code) + if exclude_id is not None: + query = query.where(InviteCode.id != exclude_id) + if session.scalar(query) is None: + return code + raise BizException("邀请码生成失败,请稍后重试", code=BizCode.BAD_REQUEST) + + def redeem_invite_code(self, session: Session, user_id: int, code: str) -> None: + normalized = normalize_invite_code(code) + if not normalized: + raise BizException("邀请码无效", code=BizCode.BAD_REQUEST) + + existing = session.scalar( + select(InviteRecord.id).where(InviteRecord.invitee_id == user_id) + ) + if existing is not None: + raise BizException("已填写过邀请码", code=BizCode.BAD_REQUEST) + + invite = session.scalar( + select(InviteCode).where(InviteCode.code == normalized).with_for_update() + ) + if invite is None: + raise BizException("邀请码无效", code=BizCode.BAD_REQUEST) + if invite.user_id == user_id: + raise BizException("不能填写自己的邀请码", code=BizCode.BAD_REQUEST) + if session.get(User, user_id) is None: + raise BizException("用户不存在", code=BizCode.NOT_FOUND) + + record = InviteRecord( + inviter_id=invite.user_id, + invitee_id=user_id, + code=normalized, + ) + session.add(record) + invite.used_count += 1 + session.flush() + + reward = quota_settings.invite_reward_amount + self.credit( + session, + invite.user_id, + reward, + int(CreditReason.INVITE_REWARD), + f"invite:{user_id}:inviter", + ) + self.credit( + session, + user_id, + reward, + int(CreditReason.INVITE_REWARD), + f"invite:{user_id}:invitee", + ) + logger.info( + "[WINDUP] 兑换邀请码 | invitee=%s inviter=%s code=%s", + user_id, + invite.user_id, + normalized, + ) service = SqlAlchemyQuotaService() diff --git a/backend/packages/app/src/windup_app/server/user/interface.py b/backend/packages/app/src/windup_app/server/user/interface.py index ec361a9a..964b06fd 100644 --- a/backend/packages/app/src/windup_app/server/user/interface.py +++ b/backend/packages/app/src/windup_app/server/user/interface.py @@ -46,7 +46,7 @@ def send_verification_code(self, email: str, purpose: str) -> None: @abstractmethod def login_by_code(self, input: LoginByCodeInput) -> LoginResult: - """邮箱+验证码登录。内测期间不自动建号。 + """邮箱+验证码登录。未知邮箱不自动建号。 :raises windup_common.exceptions.BizException: 验证码错误 / 已过期 / 账号不存在 / 账号已封禁。 """ diff --git a/backend/packages/app/src/windup_app/server/user/model.py b/backend/packages/app/src/windup_app/server/user/model.py index 31b51464..98ff047f 100644 --- a/backend/packages/app/src/windup_app/server/user/model.py +++ b/backend/packages/app/src/windup_app/server/user/model.py @@ -115,6 +115,7 @@ class RegisterInput: password: str code: str nickname: str | None = None + invite_code: str = "" @dataclass diff --git a/backend/packages/app/src/windup_app/server/user/service.py b/backend/packages/app/src/windup_app/server/user/service.py index 87dd8551..7372c4d3 100644 --- a/backend/packages/app/src/windup_app/server/user/service.py +++ b/backend/packages/app/src/windup_app/server/user/service.py @@ -25,7 +25,7 @@ from windup_common.exceptions import BizException from windup_framework.config.quota import settings as quota_settings -from windup_app.server.quota.model import CreditAccount, CreditTransaction +from windup_app.server.quota.model import CreditAccount, CreditTransaction, InviteCode from windup_app.server.user.interface import UserService from windup_app.server.user.model import ( ChangePasswordInput, @@ -49,7 +49,7 @@ JWT_SECRET = jwt_settings.secret.get_secret_value() JWT_ALGORITHM = "HS256" -ACCESS_TOKEN_EXPIRE_SECONDS = 15 * 60 # 15 分钟 +ACCESS_TOKEN_EXPIRE_SECONDS = 15 * 60 # 15 分钟 REFRESH_TOKEN_EXPIRE_SECONDS = 7 * 24 * 3600 # 7 天 # -- 密码哈希 ------------------------------------------------------------- @@ -64,6 +64,7 @@ def _verify_password(password: str, hashed: str) -> bool: """验证密码。""" return bcrypt.checkpw(password.encode(), hashed.encode()) + # -- Redis key 前缀 ------------------------------------------------------- VERIFY_COOLDOWN_KEY = "verify:cooldown:{email}" @@ -72,12 +73,12 @@ def _verify_password(password: str, hashed: str) -> bool: LOGIN_FAIL_KEY = "login:fail:{email}" LOGIN_LOCK_KEY = "login:lock:{email}" -VERIFY_CODE_TTL = 300 # 5 分钟 -COOLDOWN_TTL = 60 # 60 秒 +VERIFY_CODE_TTL = 300 # 5 分钟 +COOLDOWN_TTL = 60 # 60 秒 -LOGIN_FAIL_LIMIT = 5 # 连续错误密码上限 -LOGIN_FAIL_WINDOW = 15 * 60 # 失败计数窗口 15 分钟 -LOGIN_LOCK_DURATION = 15 * 60 # 锁定时长 15 分钟 +LOGIN_FAIL_LIMIT = 5 # 连续错误密码上限 +LOGIN_FAIL_WINDOW = 15 * 60 # 失败计数窗口 15 分钟 +LOGIN_LOCK_DURATION = 15 * 60 # 锁定时长 15 分钟 def _hash_token(token: str) -> str: @@ -176,8 +177,22 @@ def register_by_email(self, input: RegisterInput) -> LoginResult: def register_by_email_with_session( self, session: Session, input: RegisterInput ) -> LoginResult: - """邮箱+验证码+密码注册(带 session)。""" - # 校验验证码 + """邮箱+验证码+密码注册(带 session)。须填写有效邀请码。""" + from windup_app.server.quota.service import ( + normalize_invite_code, + service as quota_service, + ) + + invite_code = normalize_invite_code(input.invite_code) + if not invite_code: + raise BizException("请填写邀请码", code=BizCode.BAD_REQUEST) + + invite_exists = session.scalar( + select(InviteCode.id).where(InviteCode.code == invite_code).limit(1) + ) + if invite_exists is None: + raise BizException("邀请码无效", code=BizCode.BAD_REQUEST) + self._verify_code(input.email, input.code, "register") # 检查邮箱唯一 @@ -191,13 +206,16 @@ def register_by_email_with_session( email=input.email, password_hash=_hash_password(input.password), nickname=input.nickname, - email_verified_at=datetime.now(timezone.utc), # 注册即验证(已通过验证码校验) + email_verified_at=datetime.now( + timezone.utc + ), # 注册即验证(已通过验证码校验) ) session.add(user) session.flush() # 注册送积分 self._create_credit_account(session, user.id) + quota_service.redeem_invite_code(session, user.id, invite_code) # 注册即登录,签发 token access_token = create_access_token(user.id, user.email) @@ -280,13 +298,12 @@ def login_by_password_with_session( def send_verification_code(self, email: str, purpose: str) -> None: """发送邮箱验证码。""" - if purpose == "register": - raise BizException("内测期间暂不开放注册", code=BizCode.BAD_REQUEST) - # 频率限制 cooldown_key = VERIFY_COOLDOWN_KEY.format(email=email) if self.redis.get(cooldown_key): - raise BizException("发送过于频繁,请稍后再试", code=BizCode.TOO_MANY_REQUESTS) + raise BizException( + "发送过于频繁,请稍后再试", code=BizCode.TOO_MANY_REQUESTS + ) code = _generate_code() code_key = VERIFY_CODE_KEY.format(purpose=purpose, email=email) @@ -318,7 +335,7 @@ def login_by_code(self, input: LoginByCodeInput) -> LoginResult: def login_by_code_with_session( self, session: Session, input: LoginByCodeInput ) -> LoginResult: - """邮箱+验证码登录(带 session)。内测期间不自动建号。""" + """邮箱+验证码登录(带 session)。未知邮箱不自动建号。""" # 校验验证码 self._verify_code(input.email, input.code, "login") @@ -509,7 +526,9 @@ def get_by_id_with_session(self, session: Session, user_id: int) -> UserView | N user = session.get(User, user_id) return _to_view(user) if user else None - def get_by_email_with_session(self, session: Session, email: str) -> UserView | None: + def get_by_email_with_session( + self, session: Session, email: str + ) -> UserView | None: user = session.scalar(select(User).where(User.email == email)) return _to_view(user) if user else None @@ -540,7 +559,8 @@ def _create_credit_account(self, session: Session, user_id: int) -> None: logger.info( "[WINDUP] 注册送积分 | user_id=%s amount=%s", - user_id, quota_settings.register_gift_amount, + user_id, + quota_settings.register_gift_amount, ) def _store_refresh_token(self, jti: str, user_id: int) -> None: diff --git a/backend/packages/app/src/windup_app/web/api/auth.py b/backend/packages/app/src/windup_app/web/api/auth.py index 8570ac0c..eccc3e3e 100644 --- a/backend/packages/app/src/windup_app/web/api/auth.py +++ b/backend/packages/app/src/windup_app/web/api/auth.py @@ -13,7 +13,13 @@ from windup_framework.db import get_session -from windup_app.server.user.model import ResetPasswordInput, UpdateNicknameInput, User, UserView +from windup_app.server.user.model import ( + RegisterInput, + ResetPasswordInput, + UpdateNicknameInput, + User, + UserView, +) from windup_app.server.user.service import service logger = logging.getLogger("windup.auth.api") @@ -31,6 +37,7 @@ class RegisterRequest(BaseModel): password: str = Field(min_length=8, max_length=128) code: str = Field(min_length=6, max_length=6, description="邮箱验证码") nickname: str | None = Field(default=None, max_length=50) + invite_code: str = Field(min_length=4, max_length=16, description="邀请码") class LoginRequest(BaseModel): @@ -77,7 +84,9 @@ class ResetPasswordRequest(BaseModel): """重置密码请求(忘记密码场景)。""" email: EmailStr - code: str = Field(min_length=6, max_length=6, description="reset_password 用途的验证码") + code: str = Field( + min_length=6, max_length=6, description="reset_password 用途的验证码" + ) new_password: str = Field(min_length=8, max_length=128) @@ -111,15 +120,25 @@ class UserOut(BaseModel): @router.post("/register", response_model=Response[TokenResponse]) def register(body: RegisterRequest, session: Session = Depends(get_session)): - """邮箱+验证码+密码注册。 - - 内测期间关闭公开注册,路由与请求模型保留以便以后重新开放。 - """ - from windup_common.enums.biz_code import BizCode - from windup_common.exceptions import BizException - - del body, session - raise BizException("内测期间暂不开放注册", code=BizCode.BAD_REQUEST) + """邮箱+验证码+密码注册。须填写有效邀请码。""" + result = service.register_by_email_with_session( + session, + RegisterInput( + email=body.email, + password=body.password, + code=body.code, + nickname=body.nickname, + invite_code=body.invite_code, + ), + ) + return Response.success( + TokenResponse( + access_token=result.access_token, + refresh_token=result.refresh_token, + user=result.user, + ), + message="注册成功", + ) @router.post("/login", response_model=Response[TokenResponse]) @@ -127,7 +146,9 @@ def login(body: LoginRequest, session: Session = Depends(get_session)): """邮箱+密码+验证码登录。""" result = service.login_by_password_with_session( session, - type("LoginByPasswordInput", (), {"email": body.email, "password": body.password})(), + type( + "LoginByPasswordInput", (), {"email": body.email, "password": body.password} + )(), ) return Response.success( TokenResponse( @@ -148,7 +169,7 @@ def send_code(body: SendCodeRequest): @router.post("/login-by-code", response_model=Response[TokenResponse]) def login_by_code(body: LoginByCodeRequest, session: Session = Depends(get_session)): - """验证码登录。内测期间不自动注册。""" + """验证码登录。未知邮箱不自动建号。""" result = service.login_by_code_with_session( session, type("LoginByCodeInput", (), {"email": body.email, "code": body.code})(), @@ -191,26 +212,37 @@ def get_me(request: Request, session: Session = Depends(get_session)): if user is None: from windup_common.enums.biz_code import BizCode from windup_common.exceptions import BizException + raise BizException("用户不存在", code=BizCode.NOT_FOUND) return Response.success( UserOut( id=user.id, email=user.email, nickname=user.nickname, - email_verified_at=user.email_verified_at.isoformat() if user.email_verified_at else None, + email_verified_at=user.email_verified_at.isoformat() + if user.email_verified_at + else None, status=user.status, ) ) @router.post("/change-password", response_model=Response[None]) -def change_password(body: ChangePasswordRequest, request: Request, session: Session = Depends(get_session)): +def change_password( + body: ChangePasswordRequest, + request: Request, + session: Session = Depends(get_session), +): """修改密码。""" current_user = request.state.current_user service.change_password_with_session( session, current_user.id, - type("ChangePasswordInput", (), {"old_password": body.old_password, "new_password": body.new_password})(), + type( + "ChangePasswordInput", + (), + {"old_password": body.old_password, "new_password": body.new_password}, + )(), ) return Response.success(None, message="密码修改成功") @@ -220,13 +252,19 @@ def reset_password(body: ResetPasswordRequest, session: Session = Depends(get_se """邮箱+验证码重置密码(忘记密码)。""" service.reset_password_with_session( session, - ResetPasswordInput(email=body.email, code=body.code, new_password=body.new_password), + ResetPasswordInput( + email=body.email, code=body.code, new_password=body.new_password + ), ) return Response.success(None, message="密码重置成功") @router.patch("/profile", response_model=Response[UserOut]) -def update_nickname(body: UpdateNicknameRequest, request: Request, session: Session = Depends(get_session)): +def update_nickname( + body: UpdateNicknameRequest, + request: Request, + session: Session = Depends(get_session), +): """修改当前用户昵称。""" current_user = request.state.current_user user_view = service.update_nickname_with_session( @@ -237,7 +275,9 @@ def update_nickname(body: UpdateNicknameRequest, request: Request, session: Sess id=user_view.id, email=user_view.email, nickname=user_view.nickname, - email_verified_at=user_view.email_verified_at.isoformat() if user_view.email_verified_at else None, + email_verified_at=user_view.email_verified_at.isoformat() + if user_view.email_verified_at + else None, status=user_view.status, ), message="昵称修改成功", diff --git a/backend/packages/app/src/windup_app/web/api/quota.py b/backend/packages/app/src/windup_app/web/api/quota.py index 90d3f56c..05577048 100644 --- a/backend/packages/app/src/windup_app/web/api/quota.py +++ b/backend/packages/app/src/windup_app/web/api/quota.py @@ -4,11 +4,9 @@ -------- GET /quota/balance 查询积分余额 GET /quota/transactions 查询积分流水(分页) - -暂不实现: -POST /quota/invite/redeem 兑换邀请码 GET /quota/invite/code 获取我的邀请码 POST /quota/invite/generate 生成新邀请码 +POST /quota/invite/redeem 兑换邀请码 """ from __future__ import annotations @@ -17,7 +15,7 @@ from datetime import datetime from fastapi import APIRouter, Depends, Query, Request -from pydantic import BaseModel, ConfigDict +from pydantic import BaseModel, ConfigDict, Field from sqlalchemy.orm import Session from windup_common.result import ListResponse, Response @@ -63,15 +61,19 @@ class CreditTransactionOut(BaseModel): create_at: datetime -# -- 暂不实现 ---------------------------------------------------------------- -# -# class InviteCodeOut(BaseModel): -# """邀请码响应。""" -# ... -# -# class RedeemRequest(BaseModel): -# """兑换邀请码请求。""" -# ... +class InviteCodeOut(BaseModel): + """邀请码响应。""" + + code: str + used_count: int + create_at: datetime + update_at: datetime + + +class RedeemRequest(BaseModel): + """兑换邀请码请求。""" + + code: str = Field(min_length=4, max_length=16) # -- 端点 ---------------------------------------------------------------- @@ -102,7 +104,9 @@ def list_transactions( ) -> ListResponse[CreditTransactionOut]: """查询积分流水(分页)。""" user_id = request.state.current_user.id - txns, total = service.list_transactions(session, user_id, page=page, page_size=page_size) + txns, total = service.list_transactions( + session, user_id, page=page, page_size=page_size + ) return ListResponse.success( [CreditTransactionOut.model_validate(t) for t in txns], total=total, @@ -111,13 +115,47 @@ def list_transactions( ) -# -- 邀请码端点(暂不实现)-------------------------------------------------- -# -# @router.get("/invite/code") -# def get_invite_code(...): ... -# -# @router.post("/invite/generate") -# def generate_invite_code(...): ... -# -# @router.post("/invite/redeem") -# def redeem_invite_code(...): ... +@router.get("/invite/code", response_model=Response[InviteCodeOut]) +def get_invite_code( + request: Request, + session: Session = Depends(get_session), +) -> Response[InviteCodeOut]: + """获取当前用户邀请码;没有则生成。""" + view = service.get_invite_code(session, request.state.current_user.id) + return Response.success( + InviteCodeOut( + code=view.code, + used_count=view.used_count, + create_at=view.create_at, + update_at=view.update_at, + ) + ) + + +@router.post("/invite/generate", response_model=Response[InviteCodeOut]) +def generate_invite_code( + request: Request, + session: Session = Depends(get_session), +) -> Response[InviteCodeOut]: + """生成或轮换当前用户邀请码。""" + view = service.generate_invite_code(session, request.state.current_user.id) + return Response.success( + InviteCodeOut( + code=view.code, + used_count=view.used_count, + create_at=view.create_at, + update_at=view.update_at, + ), + message="邀请码已更新", + ) + + +@router.post("/invite/redeem", response_model=Response[None]) +def redeem_invite_code( + body: RedeemRequest, + request: Request, + session: Session = Depends(get_session), +) -> Response[None]: + """已登录用户补填邀请码,双方发放邀请奖励。每人限一次。""" + service.redeem_invite_code(session, request.state.current_user.id, body.code) + return Response.success(None, message="邀请码填写成功") diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py index d8eafbda..ca1e1f4b 100644 --- a/backend/tests/conftest.py +++ b/backend/tests/conftest.py @@ -22,11 +22,17 @@ from windup_app.bootstrap.app import create_app from windup_app.server.character.model import Character from windup_app.server.project.model import Project -from windup_app.server.quota.model import CreditAccount, CreditTransaction +from windup_app.server.quota.model import ( + CreditAccount, + CreditTransaction, + InviteCode, + InviteRecord, +) from windup_app.server.user.model import User from windup_app.server.orchestrator.model import GenerationTaskRecord from windup_app.server.workflow_run.model import WorkflowRun from windup_app.server.user.service import create_access_token +from windup_framework.config.quota import settings as quota_settings from windup_framework.db import Base, get_session @@ -35,6 +41,25 @@ def _disable_generation_execution(app): app.state.run_image_task = lambda *args: None +def seed_invite_code(session, code: str = "AB23CD45") -> str: + """预置一个可重复使用的邀请码,供注册测试使用。""" + inviter = User(email=f"inviter-{code.lower()}@example.com", password_hash="x") + session.add(inviter) + session.flush() + session.add(InviteCode(user_id=inviter.id, code=code, used_count=0)) + session.add( + CreditAccount( + user_id=inviter.id, + balance=quota_settings.register_gift_amount, + frozen=0, + total_earned=quota_settings.register_gift_amount, + total_spent=0, + ) + ) + session.flush() + return code + + def _make_engine(): """单连接内存 SQLite;``check_same_thread=False`` 让 TestClient 线程可共用。""" return create_engine( @@ -44,15 +69,29 @@ def _make_engine(): ) +@pytest.fixture() +def invite_code(db_session): + return seed_invite_code(db_session) + + @pytest.fixture() def engine(): """建好 ``windup_project`` 和 ``windup_user`` 表的内存 engine。""" engine = _make_engine() - Base.metadata.create_all(engine, tables=[ - Project.__table__, User.__table__, Character.__table__, WorkflowRun.__table__, - CreditAccount.__table__, CreditTransaction.__table__, - GenerationTaskRecord.__table__, - ]) + Base.metadata.create_all( + engine, + tables=[ + Project.__table__, + User.__table__, + Character.__table__, + WorkflowRun.__table__, + CreditAccount.__table__, + CreditTransaction.__table__, + InviteCode.__table__, + InviteRecord.__table__, + GenerationTaskRecord.__table__, + ], + ) yield engine engine.dispose() diff --git a/backend/tests/test_auth_registration_closed.py b/backend/tests/test_auth_registration_closed.py index aa77c335..731e227a 100644 --- a/backend/tests/test_auth_registration_closed.py +++ b/backend/tests/test_auth_registration_closed.py @@ -1,9 +1,11 @@ -"""内测关闭公开注册:公开建号路径必须被拒绝。""" +"""注册须填写有效邀请码;无邀请码不得建号。""" from windup_common.enums.biz_code import BizCode +from windup_app.server.user.model import User -def test_register_endpoint_rejects_public_signup(client): + +def test_register_endpoint_requires_invite_code(client): resp = client.post( "/auth/register", json={ @@ -15,16 +17,25 @@ def test_register_endpoint_rejects_public_signup(client): assert resp.status_code == 200 body = resp.json() assert body["code"] == BizCode.BAD_REQUEST - assert body["message"] == "内测期间暂不开放注册" - assert body["data"] is None + assert body["data"] is not None + assert any("invite_code" in str(item) for item in body["data"]) -def test_send_code_rejects_register_purpose(client): +def test_register_endpoint_rejects_invalid_invite_code(client, db_session): resp = client.post( - "/auth/send-code", - json={"email": "new@example.com", "purpose": "register"}, + "/auth/register", + json={ + "email": "new@example.com", + "password": "password123", + "code": "123456", + "invite_code": "NOPE1234", + }, ) assert resp.status_code == 200 body = resp.json() assert body["code"] == BizCode.BAD_REQUEST - assert body["message"] == "内测期间暂不开放注册" + assert body["message"] == "邀请码无效" + assert ( + db_session.query(User).filter(User.email == "new@example.com").one_or_none() + is None + ) diff --git a/backend/tests/test_quota.py b/backend/tests/test_quota.py index 64364464..a8725fa5 100644 --- a/backend/tests/test_quota.py +++ b/backend/tests/test_quota.py @@ -106,18 +106,25 @@ def test_get_nonexistent_account(self, db_session, quota_service): class TestReserveCredit: def test_reserve_success(self, db_session, quota_service, user_with_account): uid = user_with_account.id - quota_service.reserve_credit(db_session, uid, quota_settings.generate_image_cost, "task:1") + quota_service.reserve_credit( + db_session, uid, quota_settings.generate_image_cost, "task:1" + ) account = db_session.scalar( select(CreditAccount).where(CreditAccount.user_id == uid) ) - assert account.balance == quota_settings.register_gift_amount - quota_settings.generate_image_cost + assert ( + account.balance + == quota_settings.register_gift_amount - quota_settings.generate_image_cost + ) assert account.frozen == quota_settings.generate_image_cost def test_reserve_insufficient(self, db_session, quota_service, user_with_account): uid = user_with_account.id with pytest.raises(BizException, match="积分不足"): - quota_service.reserve_credit(db_session, uid, quota_settings.register_gift_amount + 1, "task:2") + quota_service.reserve_credit( + db_session, uid, quota_settings.register_gift_amount + 1, "task:2" + ) def test_reserve_nonexistent_account(self, db_session, quota_service): with pytest.raises(BizException, match="积分账户不存在"): @@ -125,11 +132,14 @@ def test_reserve_nonexistent_account(self, db_session, quota_service): def test_reserve_writes_txn(self, db_session, quota_service, user_with_account): uid = user_with_account.id - quota_service.reserve_credit(db_session, uid, quota_settings.generate_image_cost, "task:3") + quota_service.reserve_credit( + db_session, uid, quota_settings.generate_image_cost, "task:3" + ) txn = db_session.scalar( - select(CreditTransaction) - .where(CreditTransaction.user_id == uid, CreditTransaction.ref_id == "task:3") + select(CreditTransaction).where( + CreditTransaction.user_id == uid, CreditTransaction.ref_id == "task:3" + ) ) assert txn is not None assert txn.delta == -quota_settings.generate_image_cost @@ -157,8 +167,12 @@ def test_capture_full(self, db_session, quota_service, user_with_account): def test_capture_partial_refund(self, db_session, quota_service, user_with_account): """冻结 50,实际扣 30,差额 20 退回。""" uid = user_with_account.id - quota_service.reserve_credit(db_session, uid, quota_settings.generate_action_cost, "task:4") - quota_service.capture_credit(db_session, uid, 30, "task:4", quota_settings.generate_action_cost) + quota_service.reserve_credit( + db_session, uid, quota_settings.generate_action_cost, "task:4" + ) + quota_service.capture_credit( + db_session, uid, 30, "task:4", quota_settings.generate_action_cost + ) account = db_session.scalar( select(CreditAccount).where(CreditAccount.user_id == uid) @@ -167,11 +181,17 @@ def test_capture_partial_refund(self, db_session, quota_service, user_with_accou assert account.frozen == 0 assert account.total_spent == 30 - def test_capture_writes_txn_and_refund(self, db_session, quota_service, user_with_account): + def test_capture_writes_txn_and_refund( + self, db_session, quota_service, user_with_account + ): """有差额退回时应写两条流水:扣减 + 退款。""" uid = user_with_account.id - quota_service.reserve_credit(db_session, uid, quota_settings.generate_action_cost, "task:5") - quota_service.capture_credit(db_session, uid, 30, "task:5", quota_settings.generate_action_cost) + quota_service.reserve_credit( + db_session, uid, quota_settings.generate_action_cost, "task:5" + ) + quota_service.capture_credit( + db_session, uid, 30, "task:5", quota_settings.generate_action_cost + ) txns = db_session.scalars( select(CreditTransaction).where(CreditTransaction.user_id == uid) @@ -180,10 +200,14 @@ def test_capture_writes_txn_and_refund(self, db_session, quota_service, user_wit assert CreditReason.CAPTURED in reasons assert CreditReason.REFUND in reasons - def test_capture_insufficient_frozen(self, db_session, quota_service, user_with_account): + def test_capture_insufficient_frozen( + self, db_session, quota_service, user_with_account + ): """冻结额度不足时应抛异常。""" uid = user_with_account.id - quota_service.reserve_credit(db_session, uid, quota_settings.generate_image_cost, "task:6") + quota_service.reserve_credit( + db_session, uid, quota_settings.generate_image_cost, "task:6" + ) with pytest.raises(BizException, match="冻结额度不足"): quota_service.capture_credit(db_session, uid, 100, "task:6", 100) @@ -198,8 +222,12 @@ def test_capture_nonexistent_account(self, db_session, quota_service): class TestReleaseCredit: def test_release_success(self, db_session, quota_service, user_with_account): uid = user_with_account.id - quota_service.reserve_credit(db_session, uid, quota_settings.generate_image_cost, "task:7") - quota_service.release_credit(db_session, uid, quota_settings.generate_image_cost, "task:7") + quota_service.reserve_credit( + db_session, uid, quota_settings.generate_image_cost, "task:7" + ) + quota_service.release_credit( + db_session, uid, quota_settings.generate_image_cost, "task:7" + ) account = db_session.scalar( select(CreditAccount).where(CreditAccount.user_id == uid) @@ -209,17 +237,25 @@ def test_release_success(self, db_session, quota_service, user_with_account): def test_release_writes_txn(self, db_session, quota_service, user_with_account): uid = user_with_account.id - quota_service.reserve_credit(db_session, uid, quota_settings.generate_image_cost, "task:8") - quota_service.release_credit(db_session, uid, quota_settings.generate_image_cost, "task:8") + quota_service.reserve_credit( + db_session, uid, quota_settings.generate_image_cost, "task:8" + ) + quota_service.release_credit( + db_session, uid, quota_settings.generate_image_cost, "task:8" + ) txn = db_session.scalar( - select(CreditTransaction) - .where(CreditTransaction.user_id == uid, CreditTransaction.reason == CreditReason.REFUND) + select(CreditTransaction).where( + CreditTransaction.user_id == uid, + CreditTransaction.reason == CreditReason.REFUND, + ) ) assert txn is not None assert txn.delta == quota_settings.generate_image_cost - def test_release_insufficient_frozen(self, db_session, quota_service, user_with_account): + def test_release_insufficient_frozen( + self, db_session, quota_service, user_with_account + ): uid = user_with_account.id with pytest.raises(BizException, match="冻结额度不足"): quota_service.release_credit(db_session, uid, 100, "task:9") @@ -248,8 +284,9 @@ def test_credit_writes_txn(self, db_session, quota_service, user_with_account): quota_service.credit(db_session, uid, 50, CreditReason.ADMIN_ADJUST, "admin:2") txn = db_session.scalar( - select(CreditTransaction) - .where(CreditTransaction.user_id == uid, CreditTransaction.ref_id == "admin:2") + select(CreditTransaction).where( + CreditTransaction.user_id == uid, CreditTransaction.ref_id == "admin:2" + ) ) assert txn is not None assert txn.delta == 50 @@ -290,8 +327,16 @@ def test_list_empty(self, db_session, quota_service, user_with_account): def test_list_after_operations(self, db_session, quota_service, user_with_account): uid = user_with_account.id - quota_service.reserve_credit(db_session, uid, quota_settings.generate_image_cost, "task:10") - quota_service.capture_credit(db_session, uid, quota_settings.generate_image_cost, "task:10", quota_settings.generate_image_cost) + quota_service.reserve_credit( + db_session, uid, quota_settings.generate_image_cost, "task:10" + ) + quota_service.capture_credit( + db_session, + uid, + quota_settings.generate_image_cost, + "task:10", + quota_settings.generate_image_cost, + ) txns, total = quota_service.list_transactions(db_session, uid) assert total >= 2 @@ -300,13 +345,19 @@ def test_list_after_operations(self, db_session, quota_service, user_with_accoun def test_list_pagination(self, db_session, quota_service, user_with_account): uid = user_with_account.id for i in range(5): - quota_service.credit(db_session, uid, 10, CreditReason.ADMIN_ADJUST, f"page:{i}") + quota_service.credit( + db_session, uid, 10, CreditReason.ADMIN_ADJUST, f"page:{i}" + ) - txns_p1, total = quota_service.list_transactions(db_session, uid, page=1, page_size=2) + txns_p1, total = quota_service.list_transactions( + db_session, uid, page=1, page_size=2 + ) assert total == 5 assert len(txns_p1) == 2 - txns_p3, _ = quota_service.list_transactions(db_session, uid, page=3, page_size=2) + txns_p3, _ = quota_service.list_transactions( + db_session, uid, page=3, page_size=2 + ) assert len(txns_p3) == 1 # 最后一页只有 1 条 def test_list_other_user_empty(self, db_session, quota_service, user_with_account): @@ -409,7 +460,9 @@ def test_list_transactions_empty(self, auth_quota_client): assert data["data"] == [] assert data["total"] == 0 - def test_list_transactions_pagination(self, auth_quota_client, db_session, user_with_account): + def test_list_transactions_pagination( + self, auth_quota_client, db_session, user_with_account + ): """先写入几条流水,再通过 API 分页查询。""" uid = user_with_account.id service = SqlAlchemyQuotaService() @@ -423,7 +476,9 @@ def test_list_transactions_pagination(self, auth_quota_client, db_session, user_ assert data["total"] == 5 assert len(data["data"]) == 2 - def test_list_transactions_default_pagination(self, auth_quota_client, db_session, user_with_account): + def test_list_transactions_default_pagination( + self, auth_quota_client, db_session, user_with_account + ): """默认分页参数。""" uid = user_with_account.id service = SqlAlchemyQuotaService() @@ -441,3 +496,80 @@ def test_unauthenticated_access(self, client): assert resp.status_code == 200 data = resp.json() assert data["code"] == 401 + + +def _gift_account(session: Session, user_id: int) -> None: + session.add( + CreditAccount( + user_id=user_id, + balance=quota_settings.register_gift_amount, + frozen=0, + total_earned=quota_settings.register_gift_amount, + total_spent=0, + ) + ) + session.flush() + + +class TestInviteCode: + """邀请码生成、查询与兑换。""" + + def test_get_invite_code_creates_when_missing(self, auth_quota_client): + resp = auth_quota_client.get("/quota/invite/code") + assert resp.status_code == 200 + data = resp.json() + assert data["code"] == 200 + assert len(data["data"]["code"]) == 8 + assert data["data"]["used_count"] == 0 + + again = auth_quota_client.get("/quota/invite/code") + assert again.json()["data"]["code"] == data["data"]["code"] + + def test_generate_invite_code_rotates(self, auth_quota_client): + first = auth_quota_client.get("/quota/invite/code").json()["data"]["code"] + second = auth_quota_client.post("/quota/invite/generate").json()["data"]["code"] + assert second != first + assert len(second) == 8 + + def test_redeem_invite_code_rewards_both_users(self, db_session, quota_service): + from windup_app.server.user.model import User + + inviter = User(email="host@example.com", password_hash="x") + invitee = User(email="guest@example.com", password_hash="x") + db_session.add_all([inviter, invitee]) + db_session.flush() + _gift_account(db_session, inviter.id) + _gift_account(db_session, invitee.id) + view = quota_service.generate_invite_code(db_session, inviter.id) + + quota_service.redeem_invite_code(db_session, invitee.id, view.code.lower()) + + host = quota_service.get_account(db_session, inviter.id) + guest = quota_service.get_account(db_session, invitee.id) + assert ( + host.balance + == quota_settings.register_gift_amount + quota_settings.invite_reward_amount + ) + assert ( + guest.balance + == quota_settings.register_gift_amount + quota_settings.invite_reward_amount + ) + + def test_redeem_rejects_own_code_and_repeat(self, db_session, quota_service): + from windup_app.server.user.model import User + from windup_common.exceptions import BizException + + host = User(email="self@example.com", password_hash="x") + guest = User(email="once@example.com", password_hash="x") + db_session.add_all([host, guest]) + db_session.flush() + _gift_account(db_session, host.id) + _gift_account(db_session, guest.id) + view = quota_service.generate_invite_code(db_session, host.id) + + with pytest.raises(BizException, match="不能填写自己的邀请码"): + quota_service.redeem_invite_code(db_session, host.id, view.code) + + quota_service.redeem_invite_code(db_session, guest.id, view.code) + with pytest.raises(BizException, match="已填写过邀请码"): + quota_service.redeem_invite_code(db_session, guest.id, view.code) diff --git a/backend/tests/test_user_service.py b/backend/tests/test_user_service.py index 184bd356..61fff742 100644 --- a/backend/tests/test_user_service.py +++ b/backend/tests/test_user_service.py @@ -31,6 +31,12 @@ # -- Fixtures ------------------------------------------------------------ +@pytest.fixture(autouse=True) +def _seed_invite(request): + if "db_session" in request.fixturenames: + request.getfixturevalue("invite_code") + + @pytest.fixture() def mock_redis(): """Mock Redis 客户端。""" @@ -129,6 +135,7 @@ def test_register_success(db_session, service, mock_email): email="new@example.com", password="password123", code="123456", + invite_code="AB23CD45", ) result = service.register_by_email_with_session(db_session, input_data) @@ -152,19 +159,21 @@ def test_register_creates_credit_account(db_session, service, mock_email): email="credit@example.com", password="password123", code="123456", + invite_code="AB23CD45", ) result = service.register_by_email_with_session(db_session, input_data) user_id = result.user.id + expected = quota_settings.register_gift_amount + quota_settings.invite_reward_amount # 验证积分账户已创建 account = db_session.scalar( select(CreditAccount).where(CreditAccount.user_id == user_id) ) assert account is not None - assert account.balance == quota_settings.register_gift_amount + assert account.balance == expected assert account.frozen == 0 - assert account.total_earned == quota_settings.register_gift_amount + assert account.total_earned == expected assert account.total_spent == 0 # 验证赠送流水已记录 @@ -183,7 +192,12 @@ def test_register_creates_credit_account(db_session, service, mock_email): def test_register_duplicate_email(db_session, service): # 先注册一个用户 service._redis.get.return_value = "123456" - input_data = RegisterInput(email="dup@example.com", password="pass123", code="123456") + input_data = RegisterInput( + email="dup@example.com", + password="pass123", + code="123456", + invite_code="AB23CD45", + ) service.register_by_email_with_session(db_session, input_data) # 尝试重复注册 @@ -198,6 +212,7 @@ def test_register_wrong_code(db_session, service): email="new@example.com", password="password123", code="999999", # 错误验证码 + invite_code="AB23CD45", ) with pytest.raises(BizException, match="验证码错误"): @@ -211,6 +226,7 @@ def test_register_expired_code(db_session, service): email="new@example.com", password="password123", code="123456", + invite_code="AB23CD45", ) with pytest.raises(BizException, match="验证码已过期"): @@ -223,7 +239,12 @@ def test_register_expired_code(db_session, service): def test_login_success(db_session, service, mock_email): # 先注册 service._redis.get.return_value = "123456" - register_input = RegisterInput(email="login@example.com", password="pass123", code="123456") + register_input = RegisterInput( + email="login@example.com", + password="pass123", + code="123456", + invite_code="AB23CD45", + ) service.register_by_email_with_session(db_session, register_input) # 登录(不需要验证码) @@ -238,7 +259,12 @@ def test_login_success(db_session, service, mock_email): def test_login_wrong_password(db_session, service, mock_email): # 先注册 service._redis.get.return_value = "123456" - register_input = RegisterInput(email="login@example.com", password="pass123", code="123456") + register_input = RegisterInput( + email="login@example.com", + password="pass123", + code="123456", + invite_code="AB23CD45", + ) service.register_by_email_with_session(db_session, register_input) # 密码错误 @@ -262,11 +288,17 @@ def test_login_nonexistent_user(db_session, service): def test_login_banned_user(db_session, service, mock_email): # 先注册 service._redis.get.return_value = "123456" - register_input = RegisterInput(email="banned@example.com", password="pass123", code="123456") + register_input = RegisterInput( + email="banned@example.com", + password="pass123", + code="123456", + invite_code="AB23CD45", + ) service.register_by_email_with_session(db_session, register_input) # 封禁用户 from sqlalchemy import select + user = db_session.scalar(select(User).where(User.email == "banned@example.com")) user.status = UserStatus.BANNED db_session.flush() @@ -282,7 +314,9 @@ def test_login_banned_user(db_session, service, mock_email): # -- 验证码登录测试 ------------------------------------------------------ -def test_login_by_code_unknown_email_does_not_create_user(db_session, service, mock_email): +def test_login_by_code_unknown_email_does_not_create_user( + db_session, service, mock_email +): """内测关闭公开注册后,验证码登录不得自动建号。""" from sqlalchemy import select @@ -295,16 +329,17 @@ def test_login_by_code_unknown_email_does_not_create_user(db_session, service, m from windup_common.enums.biz_code import BizCode assert exc.value.code == BizCode.NOT_FOUND - assert db_session.scalar(select(User).where(User.email == "code@example.com")) is None + assert ( + db_session.scalar(select(User).where(User.email == "code@example.com")) is None + ) -def test_send_verification_code_rejects_register_purpose(service, mock_email): +def test_send_verification_code_allows_register_purpose(service, mock_email): service._redis.get.return_value = None - with pytest.raises(BizException, match="内测期间暂不开放注册"): - service.send_verification_code("new@example.com", "register") + service.send_verification_code("new@example.com", "register") - mock_email.send_verification_code.assert_not_called() + mock_email.send_verification_code.assert_called_once() def test_login_by_code_banned_user(db_session, service, mock_email): @@ -313,9 +348,16 @@ def test_login_by_code_banned_user(db_session, service, mock_email): service._redis.get.return_value = "123456" service.register_by_email_with_session( db_session, - RegisterInput(email="banned-code@example.com", password="pass123", code="123456"), + RegisterInput( + email="banned-code@example.com", + password="pass123", + code="123456", + invite_code="AB23CD45", + ), + ) + user = db_session.scalar( + select(User).where(User.email == "banned-code@example.com") ) - user = db_session.scalar(select(User).where(User.email == "banned-code@example.com")) user.status = UserStatus.BANNED db_session.flush() @@ -345,7 +387,12 @@ def test_login_by_code_marks_unverified_email(db_session, service): def test_login_by_code_existing_user(db_session, service, mock_email): # 先注册 service._redis.get.return_value = "123456" - register_input = RegisterInput(email="exist@example.com", password="pass123", code="123456") + register_input = RegisterInput( + email="exist@example.com", + password="pass123", + code="123456", + invite_code="AB23CD45", + ) service.register_by_email_with_session(db_session, register_input) # 验证码登录 @@ -451,16 +498,25 @@ def test_refresh_tokens_concurrent_reuse(service, mock_redis): def test_change_password(db_session, service, mock_email): # 先注册 service._redis.get.return_value = "123456" - register_input = RegisterInput(email="change@example.com", password="oldpass123", code="123456") + register_input = RegisterInput( + email="change@example.com", + password="oldpass123", + code="123456", + invite_code="AB23CD45", + ) result = service.register_by_email_with_session(db_session, register_input) # 修改密码 - change_input = ChangePasswordInput(old_password="oldpass123", new_password="newpass123") + change_input = ChangePasswordInput( + old_password="oldpass123", new_password="newpass123" + ) service.change_password_with_session(db_session, result.user.id, change_input) # 用新密码登录 service._redis.get.return_value = None - login_input = LoginByPasswordInput(email="change@example.com", password="newpass123") + login_input = LoginByPasswordInput( + email="change@example.com", password="newpass123" + ) login_result = service.login_by_password_with_session(db_session, login_input) assert login_result.user.email == "change@example.com" @@ -469,7 +525,12 @@ def test_change_password(db_session, service, mock_email): def test_change_password_wrong_old(db_session, service, mock_email): # 先注册 service._redis.get.return_value = "123456" - register_input = RegisterInput(email="change@example.com", password="oldpass123", code="123456") + register_input = RegisterInput( + email="change@example.com", + password="oldpass123", + code="123456", + invite_code="AB23CD45", + ) result = service.register_by_email_with_session(db_session, register_input) # 旧密码错误 @@ -485,11 +546,19 @@ def test_change_password_wrong_old(db_session, service, mock_email): def test_update_nickname(db_session, service, mock_email): """修改昵称后立即生效。""" service._redis.get.return_value = "123456" - register_input = RegisterInput(email="nick@example.com", password="pass1234", code="123456", nickname="旧昵称") + register_input = RegisterInput( + email="nick@example.com", + password="pass1234", + code="123456", + nickname="旧昵称", + invite_code="AB23CD45", + ) result = service.register_by_email_with_session(db_session, register_input) update_input = UpdateNicknameInput(nickname="新昵称") - user_view = service.update_nickname_with_session(db_session, result.user.id, update_input) + user_view = service.update_nickname_with_session( + db_session, result.user.id, update_input + ) assert user_view.nickname == "新昵称" assert user_view.id == result.user.id @@ -498,12 +567,19 @@ def test_update_nickname(db_session, service, mock_email): def test_update_nickname_max_length(db_session, service, mock_email): """昵称长度上限 50。""" service._redis.get.return_value = "123456" - register_input = RegisterInput(email="nick2@example.com", password="pass1234", code="123456") + register_input = RegisterInput( + email="nick2@example.com", + password="pass1234", + code="123456", + invite_code="AB23CD45", + ) result = service.register_by_email_with_session(db_session, register_input) long_nickname = "a" * 50 update_input = UpdateNicknameInput(nickname=long_nickname) - user_view = service.update_nickname_with_session(db_session, result.user.id, update_input) + user_view = service.update_nickname_with_session( + db_session, result.user.id, update_input + ) assert user_view.nickname == long_nickname @@ -523,12 +599,19 @@ def test_reset_password(db_session, service, mock_email): """邮箱+验证码重置密码后,新密码可登录。""" # 先注册 service._redis.get.return_value = "123456" - register_input = RegisterInput(email="reset@example.com", password="oldpass123", code="123456") + register_input = RegisterInput( + email="reset@example.com", + password="oldpass123", + code="123456", + invite_code="AB23CD45", + ) service.register_by_email_with_session(db_session, register_input) # 重置密码(验证码 purpose 为 reset_password) service._redis.get.return_value = "654321" - reset_input = ResetPasswordInput(email="reset@example.com", code="654321", new_password="newpass123") + reset_input = ResetPasswordInput( + email="reset@example.com", code="654321", new_password="newpass123" + ) service.reset_password_with_session(db_session, reset_input) # 用新密码登录 @@ -542,12 +625,19 @@ def test_reset_password(db_session, service, mock_email): def test_reset_password_wrong_code(db_session, service, mock_email): """验证码错误时拒绝重置。""" service._redis.get.return_value = "123456" - register_input = RegisterInput(email="reset2@example.com", password="oldpass123", code="123456") + register_input = RegisterInput( + email="reset2@example.com", + password="oldpass123", + code="123456", + invite_code="AB23CD45", + ) service.register_by_email_with_session(db_session, register_input) # 验证码错误 service._redis.get.return_value = None # 验证码过期 - reset_input = ResetPasswordInput(email="reset2@example.com", code="000000", new_password="newpass123") + reset_input = ResetPasswordInput( + email="reset2@example.com", code="000000", new_password="newpass123" + ) with pytest.raises(BizException, match="验证码已过期"): service.reset_password_with_session(db_session, reset_input) @@ -556,7 +646,9 @@ def test_reset_password_wrong_code(db_session, service, mock_email): def test_reset_password_user_not_found(db_session, service): """用户不存在时拒绝重置。""" service._redis.get.return_value = "654321" - reset_input = ResetPasswordInput(email="noexist@example.com", code="654321", new_password="newpass123") + reset_input = ResetPasswordInput( + email="noexist@example.com", code="654321", new_password="newpass123" + ) with pytest.raises(BizException, match="用户不存在"): service.reset_password_with_session(db_session, reset_input) @@ -569,7 +661,12 @@ def test_login_account_locked(db_session, service, mock_email): """账号被锁定后拒绝登录(即使密码正确)。""" # 先注册 service._redis.get.return_value = "123456" - register_input = RegisterInput(email="lock@example.com", password="pass123", code="123456") + register_input = RegisterInput( + email="lock@example.com", + password="pass123", + code="123456", + invite_code="AB23CD45", + ) service.register_by_email_with_session(db_session, register_input) # 模拟账号锁定 diff --git a/frontend/src/app/layout/app-header.test.tsx b/frontend/src/app/layout/app-header.test.tsx index 39088912..b3eb07e7 100644 --- a/frontend/src/app/layout/app-header.test.tsx +++ b/frontend/src/app/layout/app-header.test.tsx @@ -47,10 +47,26 @@ const creditAccount: CreditAccount = { function createQuotaMock(): QuotaApis & { getBalance: ReturnType listTransactions: ReturnType + getInviteCode: ReturnType + generateInviteCode: ReturnType + redeemInviteCode: ReturnType } { return { getBalance: vi.fn(async () => creditAccount), listTransactions: vi.fn(async () => ({ items: [], total: 0, page: 1, pageSize: 20 })), + getInviteCode: vi.fn(async () => ({ + code: 'AB23CD45', + usedCount: 0, + createdAt: '2026-08-12T01:02:03Z', + updatedAt: '2026-08-17T01:02:03Z', + })), + generateInviteCode: vi.fn(async () => ({ + code: 'XY89KL23', + usedCount: 0, + createdAt: '2026-08-17T03:00:00Z', + updatedAt: '2026-08-17T03:00:00Z', + })), + redeemInviteCode: vi.fn(async () => undefined), } } @@ -203,7 +219,7 @@ describe('AppHeader', () => { it('为访客提供可发现的登录入口并保留完整站内回跳地址', async () => { renderHeader('/quick-start?mode=fast#brief') - const entry = await screen.findByRole('link', { name: '登录' }) + const entry = await screen.findByRole('link', { name: '登录 / 注册' }) expect(entry.getAttribute('href')).toBe( '/?account=login&returnTo=%2Fquick-start%3Fmode%3Dfast%23brief', ) @@ -219,7 +235,7 @@ describe('AppHeader', () => { fireEvent.click(screen.getByRole('button', { name: '退出登录' })) await waitFor(() => expect(screen.getByTestId('location').textContent).toBe('/')) - expect(await screen.findByRole('link', { name: '登录' })).toBeTruthy() + expect(await screen.findByRole('link', { name: '登录 / 注册' })).toBeTruthy() expect(apis.logout).toHaveBeenCalledWith('rotated-refresh-token') }) @@ -233,7 +249,7 @@ describe('AppHeader', () => { fireEvent.click(screen.getByRole('button', { name: '退出登录' })) await waitFor(() => expect(screen.getByTestId('location').textContent).toBe('/')) - expect(await screen.findByRole('link', { name: '登录' })).toBeTruthy() + expect(await screen.findByRole('link', { name: '登录 / 注册' })).toBeTruthy() }) it('没有昵称时使用邮箱展示账号身份', async () => { diff --git a/frontend/src/app/layout/app-header.tsx b/frontend/src/app/layout/app-header.tsx index 357340fd..43263b27 100644 --- a/frontend/src/app/layout/app-header.tsx +++ b/frontend/src/app/layout/app-header.tsx @@ -201,11 +201,10 @@ export function AppHeader({ quotaApis = defaultQuotaApis }: AppHeaderProps = {}) ) : session.state.status === 'guest' ? ( - {/* 内测关闭公开注册。重新开放时改回「登录 / 注册」。 */} - 登录 + 登录 / 注册 登录 ) : ( diff --git a/frontend/src/entities/index.ts b/frontend/src/entities/index.ts index 0388df6b..b6e53f1a 100644 --- a/frontend/src/entities/index.ts +++ b/frontend/src/entities/index.ts @@ -10,6 +10,7 @@ export type { CreateQuotaApisOptions, CreditAccount, CreditTransaction, + InviteCode, QuotaApis, QuotaTransactionPageQuery, } from './quota' diff --git a/frontend/src/entities/quota/api.test.ts b/frontend/src/entities/quota/api.test.ts index 7180b6b6..bc9fa4a8 100644 --- a/frontend/src/entities/quota/api.test.ts +++ b/frontend/src/entities/quota/api.test.ts @@ -88,6 +88,46 @@ describe('createQuotaApis', () => { }) }) + it('读取并映射当前用户邀请码', async () => { + request.mockResolvedValue({ + code: 'AB23CD45', + used_count: 3, + create_at: '2026-08-12T01:02:03Z', + update_at: '2026-08-17T01:02:03Z', + }) + + await expect(createQuotaApis({ client }).getInviteCode()).resolves.toEqual({ + code: 'AB23CD45', + usedCount: 3, + createdAt: '2026-08-12T01:02:03Z', + updatedAt: '2026-08-17T01:02:03Z', + }) + expect(request).toHaveBeenCalledWith('/quota/invite/code') + }) + + it('轮换邀请码并提交兑换请求', async () => { + request + .mockResolvedValueOnce({ + code: 'XY89KL23', + used_count: 0, + create_at: '2026-08-17T03:00:00Z', + update_at: '2026-08-17T03:00:00Z', + }) + .mockResolvedValueOnce(null) + + const apis = createQuotaApis({ client }) + await expect(apis.generateInviteCode()).resolves.toMatchObject({ + code: 'XY89KL23', + usedCount: 0, + }) + await expect(apis.redeemInviteCode('ab23cd45')).resolves.toBeUndefined() + expect(request).toHaveBeenNthCalledWith(1, '/quota/invite/generate', { method: 'POST' }) + expect(request).toHaveBeenNthCalledWith(2, '/quota/invite/redeem', { + method: 'POST', + json: { code: 'ab23cd45' }, + }) + }) + it('默认适配器读取环境地址并携带当前登录凭证', async () => { vi.resetModules() const fetchFn = vi.fn(async (input) => { @@ -101,7 +141,18 @@ describe('createQuotaApis', () => { page: 1, page_size: 20, } - : { code: 200, message: 'ok', data: accountResponse } + : url.includes('/quota/invite') + ? { + code: 200, + message: 'ok', + data: { + code: 'AB23CD45', + used_count: 0, + create_at: '2026-08-12T01:02:03Z', + update_at: '2026-08-17T01:02:03Z', + }, + } + : { code: 200, message: 'ok', data: accountResponse } return Promise.resolve(new Response(JSON.stringify(body), { status: 200 })) }) vi.stubEnv('VITE_API_BASE_URL', 'https://api.windup.test') @@ -118,6 +169,7 @@ describe('createQuotaApis', () => { items: [], total: 0, }) + await expect(quotaApis.getInviteCode()).resolves.toMatchObject({ code: 'AB23CD45' }) expect(fetchFn).toHaveBeenCalledWith( 'https://api.windup.test/quota/balance', expect.objectContaining({ diff --git a/frontend/src/entities/quota/api.ts b/frontend/src/entities/quota/api.ts index 6c7c3014..1b1529c5 100644 --- a/frontend/src/entities/quota/api.ts +++ b/frontend/src/entities/quota/api.ts @@ -1,6 +1,7 @@ import type { CreditAccount, CreditTransaction, + InviteCode, QuotaApis, QuotaTransactionPageQuery, } from './types' @@ -30,6 +31,13 @@ interface CreditTransactionDto { create_at: string } +interface InviteCodeDto { + code: string + used_count: number + create_at: string + update_at: string +} + export interface CreateQuotaApisOptions extends ApiClientOptions { client?: ApiClient } @@ -60,6 +68,15 @@ function toCreditTransaction(dto: CreditTransactionDto): CreditTransaction { } } +function toInviteCode(dto: InviteCodeDto): InviteCode { + return { + code: dto.code, + usedCount: dto.used_count, + createdAt: dto.create_at, + updatedAt: dto.update_at, + } +} + export function createQuotaApis(options: CreateQuotaApisOptions = {}): QuotaApis { const { client, ...clientOptions } = options const protectedClient = @@ -82,6 +99,20 @@ export function createQuotaApis(options: CreateQuotaApisOptions = {}): QuotaApis ) return { ...result, items: result.items.map(toCreditTransaction) } }, + async getInviteCode() { + return toInviteCode(await protectedClient.request('/quota/invite/code')) + }, + async generateInviteCode() { + return toInviteCode( + await protectedClient.request('/quota/invite/generate', { method: 'POST' }), + ) + }, + async redeemInviteCode(code: string) { + await protectedClient.request('/quota/invite/redeem', { + method: 'POST', + json: { code }, + }) + }, } } @@ -96,4 +127,7 @@ function getDefaultApis(): QuotaApis { export const quotaApis: QuotaApis = { getBalance: () => getDefaultApis().getBalance(), listTransactions: (query) => getDefaultApis().listTransactions(query), + getInviteCode: () => getDefaultApis().getInviteCode(), + generateInviteCode: () => getDefaultApis().generateInviteCode(), + redeemInviteCode: (code) => getDefaultApis().redeemInviteCode(code), } diff --git a/frontend/src/entities/quota/index.ts b/frontend/src/entities/quota/index.ts index 851076b0..7bf570a2 100644 --- a/frontend/src/entities/quota/index.ts +++ b/frontend/src/entities/quota/index.ts @@ -3,6 +3,7 @@ export type { CreateQuotaApisOptions } from './api' export type { CreditAccount, CreditTransaction, + InviteCode, QuotaApis, QuotaTransactionPageQuery, } from './types' diff --git a/frontend/src/entities/quota/types.ts b/frontend/src/entities/quota/types.ts index 049b73b5..bd45f00e 100644 --- a/frontend/src/entities/quota/types.ts +++ b/frontend/src/entities/quota/types.ts @@ -26,7 +26,18 @@ export interface CreditTransaction { export type QuotaTransactionPageQuery = PageQuery +/** 当前登录用户的邀请码;usedCount 为已被成功兑换的次数。 */ +export interface InviteCode { + code: string + usedCount: number + createdAt: string + updatedAt: string +} + export interface QuotaApis { getBalance(): Promise listTransactions(query?: QuotaTransactionPageQuery): Promise> + getInviteCode(): Promise + generateInviteCode(): Promise + redeemInviteCode(code: string): Promise } diff --git a/frontend/src/entities/user/api.test.ts b/frontend/src/entities/user/api.test.ts index 5864c352..ec5068f0 100644 --- a/frontend/src/entities/user/api.test.ts +++ b/frontend/src/entities/user/api.test.ts @@ -52,6 +52,7 @@ describe('createUserApis', () => { password: 'password-123', code: '123456', nickname: 'Reader', + inviteCode: 'AB23CD45', }) await apis.login({ email: 'reader@example.com', @@ -79,6 +80,7 @@ describe('createUserApis', () => { email: 'reader@example.com', password: 'password-123', code: '123456', + invite_code: 'AB23CD45', nickname: 'Reader', }, }, @@ -185,6 +187,7 @@ describe('createUserApis', () => { password: 'password-123', code: '123456', nickname: '', + inviteCode: 'AB23CD45', }) expect(request).toHaveBeenCalledWith('/auth/register', { @@ -193,6 +196,7 @@ describe('createUserApis', () => { email: 'reader@example.com', password: 'password-123', code: '123456', + invite_code: 'AB23CD45', }, }) }) diff --git a/frontend/src/entities/user/api.ts b/frontend/src/entities/user/api.ts index f89e7809..ed4cf724 100644 --- a/frontend/src/entities/user/api.ts +++ b/frontend/src/entities/user/api.ts @@ -96,6 +96,7 @@ export function createUserApis(options: CreateUserApisOptions = {}): UserApis { email: input.email, password: input.password, code: input.code, + invite_code: input.inviteCode, ...(input.nickname ? { nickname: input.nickname } : {}), } return toAuthTokens( diff --git a/frontend/src/entities/user/index.ts b/frontend/src/entities/user/index.ts index f19e0556..b5ce0839 100644 --- a/frontend/src/entities/user/index.ts +++ b/frontend/src/entities/user/index.ts @@ -24,6 +24,7 @@ export interface UserApis { password: string code: string nickname?: string + inviteCode: string }): Promise /** 密码登录不带验证码;验证码只用于注册、免密登录与重设密码。 */ login(input: { email: string; password: string }): Promise diff --git a/frontend/src/features/account-panel/index.test.tsx b/frontend/src/features/account-panel/index.test.tsx index ddbe36ad..149d3bf1 100644 --- a/frontend/src/features/account-panel/index.test.tsx +++ b/frontend/src/features/account-panel/index.test.tsx @@ -94,31 +94,13 @@ describe('AccountPanel', () => { expect(screen.getByRole('dialog', { name: '登录 Windup' })).toBeTruthy() expect(screen.getByRole('heading', { name: '欢迎回来。' })).toBeTruthy() - expect(screen.queryByText('未注册的邮箱将在验证后自动创建账号。')).toBeNull() - expect(screen.getByText('内测期间仅支持已有账号登录。')).toBeTruthy() + expect(screen.getByText('没有账号请先用邀请码注册。')).toBeTruthy() expect(screen.queryByRole('tab', { name: '注册' })).toBeNull() - expect(screen.queryByRole('button', { name: '创建账号' })).toBeNull() - expect(screen.getByRole('link', { name: 'GitHub Issues' }).getAttribute('href')).toBe( - 'https://github.com/1024XEngineer/Windup/issues', - ) + expect(screen.getByRole('button', { name: '创建账号' })).toBeTruthy() await waitFor(() => expect(document.activeElement).toBe(screen.getByLabelText('邮箱'))) }) - it('opens a closed-registration URL as login and never starts signup', async () => { - const { apis } = renderPanel('/?account=register&returnTo=%2Fworkspace') - - expect(screen.getByRole('dialog', { name: '登录 Windup' })).toBeTruthy() - expect(screen.getByRole('heading', { name: '欢迎回来。' })).toBeTruthy() - expect(screen.queryByRole('button', { name: '创建账号' })).toBeNull() - expect(screen.queryByRole('button', { name: '继续' })).toBeNull() - expect(screen.getByRole('button', { name: '登录' })).toBeTruthy() - expect(screen.getByText(/内测期间暂不开放注册/)).toBeTruthy() - expect(apis.register).not.toHaveBeenCalled() - await waitFor(() => expect(document.activeElement).toBe(screen.getByLabelText('邮箱'))) - }) - - // 内测关闭公开注册。重新开放时取消 skip,并恢复 AccountPanel 的 register 入口。 - it.skip('opens registration as a centered, progressive form', async () => { + it('opens registration as a centered, progressive form', async () => { renderPanel('/?account=register&returnTo=%2Fworkspace') const dialog = screen.getByRole('dialog', { name: '创建 Windup 账号' }) @@ -128,6 +110,7 @@ describe('AccountPanel', () => { expect(screen.queryByText('继续搭建,')).toBeNull() expect(screen.getByTestId('register-fields').className).toContain('auth-register-fields') expect(screen.queryByRole('tablist', { name: '账号操作' })).toBeNull() + expect(screen.getByLabelText('邀请码')).toBeTruthy() expect(screen.getByLabelText('邮箱')).toBeTruthy() expect(screen.queryByLabelText('密码')).toBeNull() expect(screen.queryByLabelText('昵称(选填)')).toBeNull() @@ -159,15 +142,15 @@ describe('AccountPanel', () => { const dialog = screen.getByRole('dialog', { name: '登录 Windup' }) const closeButton = screen.getByRole('button', { name: '关闭账号面板' }) - const requestAccess = screen.getByRole('link', { name: 'GitHub Issues' }) + const entrySwitch = screen.getByRole('button', { name: '创建账号' }) - requestAccess.focus() - fireEvent.keyDown(requestAccess, { key: 'Tab' }) + entrySwitch.focus() + fireEvent.keyDown(entrySwitch, { key: 'Tab' }) expect(document.activeElement).toBe(closeButton) closeButton.focus() fireEvent.keyDown(closeButton, { key: 'Tab', shiftKey: true }) - expect(document.activeElement).toBe(requestAccess) + expect(document.activeElement).toBe(entrySwitch) expect(dialog.contains(document.activeElement)).toBe(true) }) @@ -364,20 +347,9 @@ describe('AccountPanel', () => { expect(apis.sendCode).not.toHaveBeenCalled() }) - it('does not expose a signup switch from the login panel', async () => { - vi.useFakeTimers() - renderPanel('/?account=login&returnTo=%2Fworkspace') - - expect(screen.queryByRole('button', { name: '创建账号' })).toBeNull() - expect(screen.getByTestId('location').textContent).toBe('/?account=login&returnTo=%2Fworkspace') - - await act(async () => vi.advanceTimersByTimeAsync(520)) - expect(screen.getByRole('dialog', { name: '登录 Windup' })).toBeTruthy() - expect(screen.getByTestId('location').textContent).toBe('/?account=login&returnTo=%2Fworkspace') - }) - - it.skip('preserves registration input when showing a password and returning a step', async () => { + it('preserves registration input when showing a password and returning a step', async () => { renderPanel('/?account=register') + fireEvent.change(screen.getByLabelText('邀请码'), { target: { value: 'AB23CD45' } }) fireEvent.change(screen.getByLabelText('邮箱'), { target: { value: 'new@example.com' } }) fireEvent.submit(screen.getByRole('button', { name: '继续' }).closest('form')!) @@ -390,10 +362,11 @@ describe('AccountPanel', () => { expect(((await screen.findByLabelText('邮箱')) as HTMLInputElement).value).toBe( 'new@example.com', ) + expect(((await screen.findByLabelText('邀请码')) as HTMLInputElement).value).toBe('AB23CD45') expect(screen.getByTestId('auth-motion-stage').dataset.motionDirection).toBe('backward') }) - it.skip('switches account entry only after the current panel exits', async () => { + it('switches account entry only after the current panel exits', async () => { vi.useFakeTimers() renderPanel('/?account=login&returnTo=%2Fworkspace') @@ -408,10 +381,13 @@ describe('AccountPanel', () => { ) }) - it.skip('validates each registration step and reuses the existing register API contract', async () => { + it('validates each registration step and reuses the existing register API contract', async () => { const { apis } = renderPanel('/?account=register&returnTo=%2Fworkspace') fireEvent.change(screen.getByLabelText('邮箱'), { target: { value: 'new@example.com' } }) + fireEvent.submit(screen.getByRole('button', { name: '继续' }).closest('form')!) + expect((await screen.findByRole('alert')).textContent).toContain('请填写有效邀请码') + fireEvent.change(screen.getByLabelText('邀请码'), { target: { value: 'AB23CD45' } }) fireEvent.submit(screen.getByRole('button', { name: '继续' }).closest('form')!) expect(screen.getByRole('dialog', { name: '创建 Windup 账号' })).toBeTruthy() expect(screen.getByRole('heading', { name: '为账号加一道保护' })).toBeTruthy() @@ -452,6 +428,7 @@ describe('AccountPanel', () => { email: 'new@example.com', password: 'password-123', code: '123456', + inviteCode: 'AB23CD45', nickname: '新用户', }), ) diff --git a/frontend/src/features/account-panel/index.tsx b/frontend/src/features/account-panel/index.tsx index 118043ce..21a457e0 100644 --- a/frontend/src/features/account-panel/index.tsx +++ b/frontend/src/features/account-panel/index.tsx @@ -19,6 +19,7 @@ import { EyeClosed, Keyhole, SealCheck, + Ticket, UserCircle, X, type Icon, @@ -91,7 +92,6 @@ const loginMotionCopy = [ const REGISTER_STEP_COUNT = 4 const AUTH_ICON_PROPS = { weight: 'light' as const } const AUTH_FIELD_CLASS = 'auth-screen-field w-full outline-none disabled:cursor-not-allowed' -const ACCESS_REQUEST_URL = 'https://github.com/1024XEngineer/Windup/issues' function errorMessage(error: unknown): string { return error instanceof Error && error.message ? error.message : '操作失败,请稍后重试' @@ -201,9 +201,7 @@ export function AccountPanel() { const entry = searchParams.get('account') if (entry !== 'login' && entry !== 'register') return null - // 内测期间关闭公开注册。重新开放时改回: - // return - return + return } /** 只有面板真正打开时才读取会话,关闭状态不把认证 Context 强加给应用外壳。 */ @@ -217,6 +215,7 @@ function AccountPanelDialog({ entry }: { entry: AccountEntry }) { const [showPassword, setShowPassword] = useState(false) const [code, setCode] = useState('') const [nickname, setNickname] = useState('') + const [inviteCode, setInviteCode] = useState('') const [registerStep, setRegisterStep] = useState(0) const [motionDirection, setMotionDirection] = useState('forward') const [copyIndex, setCopyIndex] = useState(0) @@ -347,7 +346,6 @@ function AccountPanelDialog({ entry }: { entry: AccountEntry }) { window.requestAnimationFrame(() => emailInputRef.current?.focus()) } - /* function switchEntry(nextEntry: AccountEntry) { leaveWithAnimation(() => { const next = new URLSearchParams(searchParams) @@ -355,7 +353,6 @@ function AccountPanelDialog({ entry }: { entry: AccountEntry }) { setSearchParams(next, { replace: true }) }) } - */ async function sendCode(): Promise { if (isSendingCode || cooldownSeconds > 0) return false @@ -396,6 +393,7 @@ function AccountPanelDialog({ entry }: { entry: AccountEntry }) { function validateRegistration(): string | null { if (!EMAIL_PATTERN.test(normalizedEmail)) return '请输入有效邮箱地址' + if (!/^[A-HJ-NP-Z2-9]{4,16}$/i.test(inviteCode.trim())) return '请填写有效邀请码' if (password.length < 8 || password.length > 128) return '密码需为 8–128 位' if (nickname.length > 50) return '昵称不能超过 50 个字符' if (!CODE_PATTERN.test(code)) return '验证码需为 6 位数字' @@ -406,6 +404,8 @@ function AccountPanelDialog({ entry }: { entry: AccountEntry }) { let validationError: string | null = null if (registerStep === 0 && !EMAIL_PATTERN.test(normalizedEmail)) { validationError = '请输入有效邮箱地址' + } else if (registerStep === 0 && !/^[A-HJ-NP-Z2-9]{4,16}$/i.test(inviteCode.trim())) { + validationError = '请填写有效邀请码' } else if (registerStep === 1 && (password.length < 8 || password.length > 128)) { validationError = '密码需为 8–128 位' } else if (registerStep === 2 && nickname.length > 50) { @@ -458,13 +458,12 @@ function AccountPanelDialog({ entry }: { entry: AccountEntry }) { email: normalizedEmail, password, code, + inviteCode: inviteCode.trim().toUpperCase(), ...(nickname.trim() ? { nickname: nickname.trim() } : {}), }) successMessage = '账号已创建,正在继续。' } else if (mode === 'code') { await session.loginByCode({ email: normalizedEmail, code }) - // 内测不自动建号。重新开放注册时改回: - // successMessage = '登录成功。如果这是你首次使用该邮箱,我们已为你创建账号。' successMessage = '登录成功,正在继续。' } else { await session.login({ email: normalizedEmail, password }) @@ -670,19 +669,34 @@ function AccountPanelDialog({ entry }: { entry: AccountEntry }) { className={`auth-motion-stage auth-motion-stage-${motionDirection}`} > {(!isRegister || registerStep === 0) && ( - + <> + {isRegister && ( + + )} + + )} {((isRegister && registerStep === 1) || (!isRegister && mode === 'password')) && ( @@ -756,10 +770,7 @@ function AccountPanelDialog({ entry }: { entry: AccountEntry }) { )} {!isRegister && mode === 'code' && ( -

- {/* 未注册的邮箱将在验证后自动创建账号。 */} - 内测期间仅支持已有账号登录。 -

+

没有账号请先用邀请码注册。

)} @@ -784,21 +795,12 @@ function AccountPanelDialog({ entry }: { entry: AccountEntry }) { - {/*

{isRegister ? '已有账号?' : '还没有账号?'}{' '}

- */} -

- 内测期间暂不开放注册。如需开通,请通过{' '} - - GitHub Issues - {' '} - 联系团队申请。 -

diff --git a/frontend/src/features/auth-session/index.test.tsx b/frontend/src/features/auth-session/index.test.tsx index 1e0d8237..126b7b6a 100644 --- a/frontend/src/features/auth-session/index.test.tsx +++ b/frontend/src/features/auth-session/index.test.tsx @@ -145,6 +145,7 @@ describe('AuthSessionProvider', () => { email: 'reader@example.com', password: 'password-123', code: '123456', + inviteCode: 'AB23CD45', }) } else if (method === 'login') { await session().login({ diff --git a/frontend/src/features/quota/index.test.ts b/frontend/src/features/quota/index.test.ts index c7c29e72..c1dd123c 100644 --- a/frontend/src/features/quota/index.test.ts +++ b/frontend/src/features/quota/index.test.ts @@ -45,6 +45,19 @@ function createQuotaApis(): QuotaApis & { page, pageSize, })), + getInviteCode: vi.fn(async () => ({ + code: 'AB23CD45', + usedCount: 0, + createdAt: '2026-08-12T01:02:03Z', + updatedAt: '2026-08-17T01:02:03Z', + })), + generateInviteCode: vi.fn(async () => ({ + code: 'XY89KL23', + usedCount: 0, + createdAt: '2026-08-17T03:00:00Z', + updatedAt: '2026-08-17T03:00:00Z', + })), + redeemInviteCode: vi.fn(async () => undefined), } } @@ -118,6 +131,7 @@ describe('quota queries', () => { }) it('为已知和未知原因码提供文案,并处理无效时间', () => { + expect(getCreditReasonLabel(2)).toBe('邀请奖励') expect(getCreditReasonLabel(4)).toBe('生成角色动作') expect(getCreditReasonLabel(99)).toBe('积分变动(原因码 99)') expect(formatCreditDateTime('not-a-date')).toBe('时间未知') diff --git a/frontend/src/pages/account/index.test.tsx b/frontend/src/pages/account/index.test.tsx index 286423b0..b8c939c5 100644 --- a/frontend/src/pages/account/index.test.tsx +++ b/frontend/src/pages/account/index.test.tsx @@ -179,6 +179,12 @@ describe('AccountPage', () => { page: 1, pageSize: 20, }) + vi.spyOn(quotaApis, 'getInviteCode').mockResolvedValue({ + code: 'AB23CD45', + usedCount: 0, + createdAt: '2026-08-12T01:02:03Z', + updatedAt: '2026-08-17T01:02:03Z', + }) renderAccount() fireEvent.click(await screen.findByRole('button', { name: '积分账户' })) @@ -189,6 +195,58 @@ describe('AccountPage', () => { expect(screen.getByText('-12')).toBeTruthy() }) + it('在积分账户展示邀请码,并允许重新生成与补填', async () => { + vi.spyOn(quotaApis, 'getBalance').mockResolvedValue({ + id: '11', + userId: '7', + balance: 90, + frozen: 10, + totalEarned: 150, + totalSpent: 50, + createdAt: '2026-08-12T01:02:03Z', + updatedAt: '2026-08-17T01:02:03Z', + }) + vi.spyOn(quotaApis, 'listTransactions').mockResolvedValue({ + items: [], + total: 0, + page: 1, + pageSize: 20, + }) + vi.spyOn(quotaApis, 'getInviteCode').mockResolvedValue({ + code: 'AB23CD45', + usedCount: 2, + createdAt: '2026-08-12T01:02:03Z', + updatedAt: '2026-08-17T01:02:03Z', + }) + vi.spyOn(quotaApis, 'generateInviteCode').mockResolvedValue({ + code: 'XY89KL23', + usedCount: 0, + createdAt: '2026-08-17T03:00:00Z', + updatedAt: '2026-08-17T03:00:00Z', + }) + const redeem = vi.spyOn(quotaApis, 'redeemInviteCode').mockResolvedValue(undefined) + + renderAccount() + fireEvent.click(await screen.findByRole('button', { name: '积分账户' })) + + expect(await screen.findByText('AB23CD45')).toBeTruthy() + expect(screen.getByText('已邀请 2 人')).toBeTruthy() + + fireEvent.click(screen.getByRole('button', { name: '重新生成' })) + expect(await screen.findByText('XY89KL23')).toBeTruthy() + expect(await screen.findByText('邀请码已更新')).toBeTruthy() + + fireEvent.change(screen.getByLabelText('填写邀请码'), { target: { value: 'nope' } }) + fireEvent.click(screen.getByRole('button', { name: '确认填写' })) + expect(await screen.findByText('请填写有效邀请码')).toBeTruthy() + expect(redeem).not.toHaveBeenCalled() + + fireEvent.change(screen.getByLabelText('填写邀请码'), { target: { value: 'mn67pq89' } }) + fireEvent.click(screen.getByRole('button', { name: '确认填写' })) + await waitFor(() => expect(redeem).toHaveBeenCalledWith('MN67PQ89')) + expect(await screen.findByText('邀请码填写成功')).toBeTruthy() + }) + it('reports a profile refresh failure without claiming the data is synchronized', async () => { const apis = createApis() apis.me.mockRejectedValue(new Error('资料读取失败')) diff --git a/frontend/src/pages/account/index.tsx b/frontend/src/pages/account/index.tsx index 65e8676f..15e4d568 100644 --- a/frontend/src/pages/account/index.tsx +++ b/frontend/src/pages/account/index.tsx @@ -1,7 +1,8 @@ import { useEffect, useId, useReducer, useRef, useState, type FormEvent } from 'react' import accountBadgeArtwork from '@/assets/account/illustrations/account-badge.webp' -import type { User } from '@/entities' +import { quotaApis } from '@/entities' +import type { InviteCode, User } from '@/entities' import { useAuthSession } from '@/features/auth-session' import { formatCreditDateTime, @@ -36,6 +37,148 @@ function formatCredits(value: number): string { return value.toLocaleString('zh-CN') } +const INVITE_CODE_PATTERN = /^[A-HJ-NP-Z2-9]{4,16}$/i + +function InviteSection() { + const redeemId = useId() + const [invite, setInvite] = useState(null) + const [loadError, setLoadError] = useState(null) + const [actionError, setActionError] = useState(null) + const [success, setSuccess] = useState(null) + const [redeemCode, setRedeemCode] = useState('') + const [busy, setBusy] = useState(false) + + useEffect(() => { + let active = true + void quotaApis.getInviteCode().then( + (view) => { + if (!active) return + setInvite(view) + setLoadError(null) + }, + (error: unknown) => { + if (!active) return + setLoadError(errorMessage(error)) + }, + ) + return () => { + active = false + } + }, []) + + async function rotateCode() { + if (busy) return + setBusy(true) + setActionError(null) + setSuccess(null) + try { + const view = await quotaApis.generateInviteCode() + setInvite(view) + setSuccess('邀请码已更新') + } catch (error) { + setActionError(errorMessage(error)) + } finally { + setBusy(false) + } + } + + async function redeem(event: FormEvent) { + event.preventDefault() + if (busy) return + const normalized = redeemCode.trim().toUpperCase() + if (!INVITE_CODE_PATTERN.test(normalized)) { + setActionError('请填写有效邀请码') + setSuccess(null) + return + } + setBusy(true) + setActionError(null) + setSuccess(null) + try { + await quotaApis.redeemInviteCode(normalized) + setRedeemCode('') + setSuccess('邀请码填写成功') + } catch (error) { + setActionError(errorMessage(error)) + } finally { + setBusy(false) + } + } + + return ( +
+

+ 邀请码 +

+

+ 把你的邀请码发给朋友即可注册。已有账号也可以在这里补填一次邀请码。 +

+ + {loadError ? ( +

+ {loadError} +

+ ) : invite ? ( +
+
+

+ {invite.code} +

+

已邀请 {invite.usedCount} 人

+
+ +
+ ) : ( +

+ 正在加载邀请码… +

+ )} + +
+ + {actionError && ( +

+ {actionError} +

+ )} + {success && ( +

+ {success} +

+ )} + +
+
+ ) +} + function QuotaSection() { const balance = useQuotaBalance(true) const transactions = useQuotaTransactions(true) @@ -66,6 +209,8 @@ function QuotaSection() { ))} + + {balance.status === 'loading' && (

正在加载积分余额… diff --git a/frontend/src/pages/landing/index.test.tsx b/frontend/src/pages/landing/index.test.tsx index 7ec45855..3190cb52 100644 --- a/frontend/src/pages/landing/index.test.tsx +++ b/frontend/src/pages/landing/index.test.tsx @@ -56,15 +56,9 @@ describe('LandingPage', () => { expect(screen.getByRole('link', { name: '登录' }).getAttribute('href')).toBe( '/?account=login&returnTo=%2Fworkspace', ) - expect((screen.getByRole('button', { name: '注册' }) as HTMLButtonElement).disabled).toBe(true) - expect(screen.queryByRole('link', { name: '注册' })).toBeNull() - expect( - screen.getByRole('link', { name: '内测暂不开放,联系团队申请' }).getAttribute('href'), - ).toBe('https://github.com/1024XEngineer/Windup/issues') - // 重新开放注册时恢复: - // expect(screen.getByRole('link', { name: '注册' }).getAttribute('href')).toBe( - // '/?account=register&returnTo=%2Fworkspace', - // ) + expect(screen.getByRole('link', { name: '注册' }).getAttribute('href')).toBe( + '/?account=register&returnTo=%2Fworkspace', + ) // Header 只处理账号入口,Hero 与收尾负责把用户带进创作。 const creationLinks = screen.getAllByRole('link', { name: '开始创作' }) expect(creationLinks).toHaveLength(2) diff --git a/frontend/src/pages/landing/marketing-header.tsx b/frontend/src/pages/landing/marketing-header.tsx index b14c0196..755aae9e 100644 --- a/frontend/src/pages/landing/marketing-header.tsx +++ b/frontend/src/pages/landing/marketing-header.tsx @@ -7,14 +7,10 @@ const loginEntry = `/?${new URLSearchParams({ returnTo: '/workspace', })}` -// 内测关闭公开注册。重新开放时恢复 registerEntry 与下方注册链接。 -const ACCESS_REQUEST_URL = 'https://github.com/1024XEngineer/Windup/issues' -/* const registerEntry = `/?${new URLSearchParams({ account: 'register', returnTo: '/workspace', })}` -*/ const sections = [ ['#capabilities', '产品能力'], @@ -81,32 +77,12 @@ export function MarketingHeader() { > 登录 - {/* 注册 - */} - - - - 内测暂不开放,联系团队申请 - - )} diff --git a/openapi.json b/openapi.json index d2d7bfcb..c9fbe2f9 100644 --- a/openapi.json +++ b/openapi.json @@ -720,6 +720,37 @@ "title": "HTTPValidationError", "type": "object" }, + "InviteCodeOut": { + "description": "邀请码响应。", + "properties": { + "code": { + "title": "Code", + "type": "string" + }, + "create_at": { + "format": "date-time", + "title": "Create At", + "type": "string" + }, + "update_at": { + "format": "date-time", + "title": "Update At", + "type": "string" + }, + "used_count": { + "title": "Used Count", + "type": "integer" + } + }, + "required": [ + "code", + "used_count", + "create_at", + "update_at" + ], + "title": "InviteCodeOut", + "type": "object" + }, "ListResponse_CharacterOut_": { "properties": { "code": { @@ -1213,6 +1244,22 @@ "title": "ProjectOut", "type": "object" }, + "RedeemRequest": { + "description": "兑换邀请码请求。", + "properties": { + "code": { + "maxLength": 16, + "minLength": 4, + "title": "Code", + "type": "string" + } + }, + "required": [ + "code" + ], + "title": "RedeemRequest", + "type": "object" + }, "RefreshRequest": { "description": "刷新 token 请求。", "properties": { @@ -1242,6 +1289,13 @@ "title": "Email", "type": "string" }, + "invite_code": { + "description": "邀请码", + "maxLength": 16, + "minLength": 4, + "title": "Invite Code", + "type": "string" + }, "nickname": { "anyOf": [ { @@ -1264,7 +1318,8 @@ "required": [ "email", "password", - "code" + "code", + "invite_code" ], "title": "RegisterRequest", "type": "object" @@ -1425,6 +1480,48 @@ "title": "Response[GenerationTaskOut]", "type": "object" }, + "Response_InviteCodeOut_": { + "properties": { + "code": { + "default": 200, + "description": "业务状态码:成功 200,失败非 200", + "title": "Code", + "type": "integer" + }, + "data": { + "anyOf": [ + { + "$ref": "#/components/schemas/InviteCodeOut" + }, + { + "type": "null" + } + ], + "description": "业务数据" + }, + "message": { + "default": "success", + "description": "提示信息", + "title": "Message", + "type": "string" + }, + "timestamp": { + "anyOf": [ + { + "format": "date-time", + "type": "string" + }, + { + "type": "null" + } + ], + "description": "响应时间;默认不携带,不携带时省略", + "title": "Timestamp" + } + }, + "title": "Response[InviteCodeOut]", + "type": "object" + }, "Response_MediaUploadResult_": { "properties": { "code": { @@ -2086,7 +2183,7 @@ }, "/auth/login-by-code": { "post": { - "description": "验证码登录。内测期间不自动注册。", + "description": "验证码登录。未知邮箱不自动建号。", "operationId": "login_by_code_auth_login_by_code_post", "requestBody": { "content": { @@ -2276,7 +2373,7 @@ }, "/auth/register": { "post": { - "description": "邮箱+验证码+密码注册。\n\n内测期间关闭公开注册,路由与请求模型保留以便以后重新开放。", + "description": "邮箱+验证码+密码注册。须填写有效邀请码。", "operationId": "register_auth_register_post", "requestBody": { "content": { @@ -3097,6 +3194,92 @@ ] } }, + "/quota/invite/code": { + "get": { + "description": "获取当前用户邀请码;没有则生成。", + "operationId": "get_invite_code_quota_invite_code_get", + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Response_InviteCodeOut_" + } + } + }, + "description": "Successful Response" + } + }, + "summary": "Get Invite Code", + "tags": [ + "quota" + ] + } + }, + "/quota/invite/generate": { + "post": { + "description": "生成或轮换当前用户邀请码。", + "operationId": "generate_invite_code_quota_invite_generate_post", + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Response_InviteCodeOut_" + } + } + }, + "description": "Successful Response" + } + }, + "summary": "Generate Invite Code", + "tags": [ + "quota" + ] + } + }, + "/quota/invite/redeem": { + "post": { + "description": "已登录用户补填邀请码,双方发放邀请奖励。每人限一次。", + "operationId": "redeem_invite_code_quota_invite_redeem_post", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RedeemRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Response_NoneType_" + } + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "summary": "Redeem Invite Code", + "tags": [ + "quota" + ] + } + }, "/quota/transactions": { "get": { "description": "查询积分流水(分页)。", From 22a8322ca1fc9350143e30f90fe95cd56b242360 Mon Sep 17 00:00:00 2001 From: xiaocheny214 <187097481+xiaocheny214@users.noreply.github.com> Date: Mon, 17 Aug 2026 18:36:37 +0800 Subject: [PATCH 02/10] =?UTF-8?q?test:=20=E8=A1=A5=E9=BD=90=E9=82=80?= =?UTF-8?q?=E8=AF=B7=E7=A0=81=E6=B3=A8=E5=86=8C=E4=B8=8E=E5=85=91=E6=8D=A2?= =?UTF-8?q?=E7=9A=84=E5=A4=B1=E8=B4=A5=E8=B7=AF=E5=BE=84=E8=A6=86=E7=9B=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 覆盖空邀请码、发码碰撞、兑换接口以及账号中心邀请码加载/生成失败。 --- backend/tests/test_auth_api.py | 25 ++++++++ backend/tests/test_quota.py | 66 +++++++++++++++++++ backend/tests/test_user_service.py | 13 ++++ frontend/src/entities/quota/api.test.ts | 2 + frontend/src/pages/account/index.test.tsx | 77 +++++++++++++++++++++++ 5 files changed, 183 insertions(+) diff --git a/backend/tests/test_auth_api.py b/backend/tests/test_auth_api.py index 16094461..0641c268 100644 --- a/backend/tests/test_auth_api.py +++ b/backend/tests/test_auth_api.py @@ -4,6 +4,8 @@ import pytest +from conftest import seed_invite_code + from windup_app.server.user.model import User from windup_app.server.user.service import _hash_password, service @@ -91,6 +93,29 @@ def test_reset_password_endpoint(auth_client, seeded_user, mock_user_redis): assert body["message"] == "密码重置成功" +def test_register_endpoint_success(client, db_session, mock_user_redis): + seed_invite_code(db_session) + db_session.commit() + mock_user_redis.get.return_value = "123456" + + resp = client.post( + "/auth/register", + json={ + "email": "invitee@example.com", + "password": "password123", + "code": "123456", + "invite_code": "AB23CD45", + "nickname": "受邀用户", + }, + ) + assert resp.status_code == 200 + body = resp.json() + assert body["code"] == 200 + assert body["message"] == "注册成功" + assert body["data"]["user"]["email"] == "invitee@example.com" + assert body["data"]["access_token"] + + def test_update_nickname_endpoint(auth_client, seeded_user, mock_user_redis): resp = auth_client.patch("/auth/profile", json={"nickname": "新昵称"}) assert resp.status_code == 200 diff --git a/backend/tests/test_quota.py b/backend/tests/test_quota.py index a8725fa5..b27bcb2b 100644 --- a/backend/tests/test_quota.py +++ b/backend/tests/test_quota.py @@ -573,3 +573,69 @@ def test_redeem_rejects_own_code_and_repeat(self, db_session, quota_service): quota_service.redeem_invite_code(db_session, guest.id, view.code) with pytest.raises(BizException, match="已填写过邀请码"): quota_service.redeem_invite_code(db_session, guest.id, view.code) + + def test_generate_invite_code_rejects_missing_user(self, db_session, quota_service): + from windup_common.exceptions import BizException + + with pytest.raises(BizException, match="用户不存在"): + quota_service.generate_invite_code(db_session, 999999) + + def test_allocate_invite_code_gives_up_on_collision( + self, db_session, quota_service, monkeypatch + ): + from windup_app.server.quota import service as quota_mod + from windup_app.server.user.model import User + from windup_common.exceptions import BizException + + taken = User(email="taken@example.com", password_hash="x") + host = User(email="alloc@example.com", password_hash="x") + db_session.add_all([taken, host]) + db_session.flush() + occupied = quota_service.generate_invite_code(db_session, taken.id) + monkeypatch.setattr(quota_mod, "_new_invite_code", lambda: occupied.code) + + with pytest.raises(BizException, match="邀请码生成失败"): + quota_service.generate_invite_code(db_session, host.id) + + def test_redeem_rejects_blank_or_unknown_code(self, db_session, quota_service): + from windup_app.server.user.model import User + from windup_common.exceptions import BizException + + guest = User(email="blank@example.com", password_hash="x") + db_session.add(guest) + db_session.flush() + _gift_account(db_session, guest.id) + + with pytest.raises(BizException, match="邀请码无效"): + quota_service.redeem_invite_code(db_session, guest.id, " ") + with pytest.raises(BizException, match="邀请码无效"): + quota_service.redeem_invite_code(db_session, guest.id, "NOPE1234") + + def test_redeem_rejects_missing_invitee(self, db_session, quota_service): + from windup_app.server.user.model import User + from windup_common.exceptions import BizException + + host = User(email="orphan-host@example.com", password_hash="x") + db_session.add(host) + db_session.flush() + _gift_account(db_session, host.id) + view = quota_service.generate_invite_code(db_session, host.id) + + with pytest.raises(BizException, match="用户不存在"): + quota_service.redeem_invite_code(db_session, 999999, view.code) + + def test_redeem_invite_code_endpoint(self, auth_quota_client, db_session): + from windup_app.server.user.model import User + + host = User(email="api-host@example.com", password_hash="x") + db_session.add(host) + db_session.flush() + _gift_account(db_session, host.id) + view = SqlAlchemyQuotaService().generate_invite_code(db_session, host.id) + db_session.commit() + + resp = auth_quota_client.post("/quota/invite/redeem", json={"code": view.code}) + assert resp.status_code == 200 + body = resp.json() + assert body["code"] == 200 + assert body["message"] == "邀请码填写成功" diff --git a/backend/tests/test_user_service.py b/backend/tests/test_user_service.py index 4c3c9a5c..57e89acc 100644 --- a/backend/tests/test_user_service.py +++ b/backend/tests/test_user_service.py @@ -273,6 +273,19 @@ def test_register_expired_code(db_session, service): service.register_by_email(db_session, input_data) +def test_register_blank_invite_code(db_session, service): + service._redis.get.return_value = "123456" + input_data = RegisterInput( + email="blank-invite@example.com", + password="password123", + code="123456", + invite_code=" ", + ) + + with pytest.raises(BizException, match="请填写邀请码"): + service.register_by_email(db_session, input_data) + + # -- 登录测试 ------------------------------------------------------------ diff --git a/frontend/src/entities/quota/api.test.ts b/frontend/src/entities/quota/api.test.ts index bc9fa4a8..d602de0b 100644 --- a/frontend/src/entities/quota/api.test.ts +++ b/frontend/src/entities/quota/api.test.ts @@ -170,6 +170,8 @@ describe('createQuotaApis', () => { total: 0, }) await expect(quotaApis.getInviteCode()).resolves.toMatchObject({ code: 'AB23CD45' }) + await expect(quotaApis.generateInviteCode()).resolves.toMatchObject({ code: 'AB23CD45' }) + await expect(quotaApis.redeemInviteCode('AB23CD45')).resolves.toBeUndefined() expect(fetchFn).toHaveBeenCalledWith( 'https://api.windup.test/quota/balance', expect.objectContaining({ diff --git a/frontend/src/pages/account/index.test.tsx b/frontend/src/pages/account/index.test.tsx index b8c939c5..5ea6d7f9 100644 --- a/frontend/src/pages/account/index.test.tsx +++ b/frontend/src/pages/account/index.test.tsx @@ -30,6 +30,25 @@ function deferred() { return { promise, resolve, reject } } +function mockQuotaReads() { + vi.spyOn(quotaApis, 'getBalance').mockResolvedValue({ + id: '11', + userId: '7', + balance: 90, + frozen: 10, + totalEarned: 150, + totalSpent: 50, + createdAt: '2026-08-12T01:02:03Z', + updatedAt: '2026-08-17T01:02:03Z', + }) + vi.spyOn(quotaApis, 'listTransactions').mockResolvedValue({ + items: [], + total: 0, + page: 1, + pageSize: 20, + }) +} + function createApis(): UserApis & Record> { return { sendCode: vi.fn(async () => undefined), @@ -247,6 +266,64 @@ describe('AccountPage', () => { expect(await screen.findByText('邀请码填写成功')).toBeTruthy() }) + it('展示邀请码加载失败', async () => { + mockQuotaReads() + vi.spyOn(quotaApis, 'getInviteCode').mockRejectedValue(new Error('邀请码服务不可用')) + + renderAccount() + fireEvent.click(await screen.findByRole('button', { name: '积分账户' })) + + expect(await screen.findByText('邀请码服务不可用')).toBeTruthy() + expect(screen.queryByText('正在加载邀请码…')).toBeNull() + expect(screen.queryByRole('button', { name: '重新生成' })).toBeNull() + }) + + it('重新生成或兑换失败时提示错误', async () => { + mockQuotaReads() + vi.spyOn(quotaApis, 'getInviteCode').mockResolvedValue({ + code: 'AB23CD45', + usedCount: 0, + createdAt: '2026-08-12T01:02:03Z', + updatedAt: '2026-08-17T01:02:03Z', + }) + vi.spyOn(quotaApis, 'generateInviteCode').mockRejectedValue(new Error('邀请码已用尽')) + vi.spyOn(quotaApis, 'redeemInviteCode').mockRejectedValue('兑换被拒绝') + + renderAccount() + fireEvent.click(await screen.findByRole('button', { name: '积分账户' })) + expect(await screen.findByText('AB23CD45')).toBeTruthy() + + fireEvent.click(screen.getByRole('button', { name: '重新生成' })) + expect(await screen.findByText('邀请码已用尽')).toBeTruthy() + + fireEvent.change(screen.getByLabelText('填写邀请码'), { target: { value: 'MN67PQ89' } }) + fireEvent.click(screen.getByRole('button', { name: '确认填写' })) + expect(await screen.findByText('操作失败,请稍后重试')).toBeTruthy() + }) + + it('邀请码加载中忽略卸载后的迟到结果', async () => { + mockQuotaReads() + const pending = deferred<{ + code: string + usedCount: number + createdAt: string + updatedAt: string + }>() + vi.spyOn(quotaApis, 'getInviteCode').mockReturnValue(pending.promise) + + const { unmount } = renderAccount() + fireEvent.click(await screen.findByRole('button', { name: '积分账户' })) + expect(await screen.findByText('正在加载邀请码…')).toBeTruthy() + unmount() + pending.resolve({ + code: 'AB23CD45', + usedCount: 0, + createdAt: '2026-08-12T01:02:03Z', + updatedAt: '2026-08-17T01:02:03Z', + }) + await Promise.resolve() + }) + it('reports a profile refresh failure without claiming the data is synchronized', async () => { const apis = createApis() apis.me.mockRejectedValue(new Error('资料读取失败')) From aca1a814720f39b7c6a6352b4d2af79f8489c37b Mon Sep 17 00:00:00 2001 From: xiaocheny214 <187097481+xiaocheny214@users.noreply.github.com> Date: Mon, 17 Aug 2026 18:40:54 +0800 Subject: [PATCH 03/10] =?UTF-8?q?fix(account):=20=E5=85=91=E6=8D=A2?= =?UTF-8?q?=E9=82=80=E8=AF=B7=E7=A0=81=E5=90=8E=E5=88=B7=E6=96=B0=E7=A7=AF?= =?UTF-8?q?=E5=88=86=E4=BD=99=E9=A2=9D=E5=92=8C=E6=B5=81=E6=B0=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 兑换成功只改了提示,同页余额与流水会停在旧数据。 --- frontend/src/pages/account/index.test.tsx | 70 +++++++++++++++++++++++ frontend/src/pages/account/index.tsx | 10 +++- 2 files changed, 78 insertions(+), 2 deletions(-) diff --git a/frontend/src/pages/account/index.test.tsx b/frontend/src/pages/account/index.test.tsx index 5ea6d7f9..480f0ad6 100644 --- a/frontend/src/pages/account/index.test.tsx +++ b/frontend/src/pages/account/index.test.tsx @@ -266,6 +266,76 @@ describe('AccountPage', () => { expect(await screen.findByText('邀请码填写成功')).toBeTruthy() }) + it('兑换成功后刷新积分余额与流水', async () => { + const getBalance = vi + .spyOn(quotaApis, 'getBalance') + .mockResolvedValueOnce({ + id: '11', + userId: '7', + balance: 90, + frozen: 10, + totalEarned: 150, + totalSpent: 50, + createdAt: '2026-08-12T01:02:03Z', + updatedAt: '2026-08-17T01:02:03Z', + }) + .mockResolvedValue({ + id: '11', + userId: '7', + balance: 140, + frozen: 10, + totalEarned: 200, + totalSpent: 50, + createdAt: '2026-08-12T01:02:03Z', + updatedAt: '2026-08-17T03:00:00Z', + }) + const listTransactions = vi + .spyOn(quotaApis, 'listTransactions') + .mockResolvedValueOnce({ + items: [], + total: 0, + page: 1, + pageSize: 20, + }) + .mockResolvedValue({ + items: [ + { + id: '22', + userId: '7', + delta: 50, + reason: 2, + billingMode: 0, + refId: 'invite:7:invitee', + balanceAfter: 140, + createdAt: '2026-08-17T03:00:00Z', + }, + ], + total: 1, + page: 1, + pageSize: 20, + }) + vi.spyOn(quotaApis, 'getInviteCode').mockResolvedValue({ + code: 'AB23CD45', + usedCount: 0, + createdAt: '2026-08-12T01:02:03Z', + updatedAt: '2026-08-17T01:02:03Z', + }) + vi.spyOn(quotaApis, 'redeemInviteCode').mockResolvedValue(undefined) + + renderAccount() + fireEvent.click(await screen.findByRole('button', { name: '积分账户' })) + expect(await screen.findByText('90')).toBeTruthy() + + fireEvent.change(screen.getByLabelText('填写邀请码'), { target: { value: 'MN67PQ89' } }) + fireEvent.click(screen.getByRole('button', { name: '确认填写' })) + + expect(await screen.findByText('邀请码填写成功')).toBeTruthy() + await waitFor(() => expect(getBalance).toHaveBeenCalledTimes(2)) + expect(listTransactions).toHaveBeenCalledTimes(2) + expect(await screen.findByText('140')).toBeTruthy() + expect(screen.getByText('邀请奖励')).toBeTruthy() + }) + it('展示邀请码加载失败', async () => { mockQuotaReads() vi.spyOn(quotaApis, 'getInviteCode').mockRejectedValue(new Error('邀请码服务不可用')) diff --git a/frontend/src/pages/account/index.tsx b/frontend/src/pages/account/index.tsx index 15e4d568..2158ccfb 100644 --- a/frontend/src/pages/account/index.tsx +++ b/frontend/src/pages/account/index.tsx @@ -39,7 +39,7 @@ function formatCredits(value: number): string { const INVITE_CODE_PATTERN = /^[A-HJ-NP-Z2-9]{4,16}$/i -function InviteSection() { +function InviteSection({ onCreditsChanged }: { onCreditsChanged(): void }) { const redeemId = useId() const [invite, setInvite] = useState(null) const [loadError, setLoadError] = useState(null) @@ -98,6 +98,7 @@ function InviteSection() { await quotaApis.redeemInviteCode(normalized) setRedeemCode('') setSuccess('邀请码填写成功') + onCreditsChanged() } catch (error) { setActionError(errorMessage(error)) } finally { @@ -209,7 +210,12 @@ function QuotaSection() { ))} - + { + balance.reload() + transactions.reload() + }} + /> {balance.status === 'loading' && (

From 9d7b70befcec2116f328e2ec9c1a7dc0e72ef378 Mon Sep 17 00:00:00 2001 From: xiaocheny214 <187097481+xiaocheny214@users.noreply.github.com> Date: Tue, 18 Aug 2026 10:20:44 +0800 Subject: [PATCH 04/10] =?UTF-8?q?revert(frontend):=20=E6=8A=8A=E9=82=80?= =?UTF-8?q?=E8=AF=B7=E7=A0=81=20UI=20=E4=BA=A4=E8=BF=98=E7=BB=99=E8=B4=A6?= =?UTF-8?q?=E5=8F=B7=E4=B8=AD=E5=BF=83=E4=B8=8E=E5=B7=A5=E4=BD=9C=E5=8F=B0?= =?UTF-8?q?=20PR?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 邀请奖励页和工作台提示已分别由 #362、#363 承担,本 PR 只保留后端接口与 OpenAPI。 --- frontend/src/app/layout/app-header.test.tsx | 22 +- frontend/src/app/layout/app-header.tsx | 5 +- frontend/src/entities/index.ts | 1 - frontend/src/entities/quota/api.test.ts | 56 +---- frontend/src/entities/quota/api.ts | 34 --- frontend/src/entities/quota/index.ts | 1 - frontend/src/entities/quota/types.ts | 11 - frontend/src/entities/user/api.test.ts | 4 - frontend/src/entities/user/api.ts | 1 - frontend/src/entities/user/index.ts | 1 - .../src/features/account-panel/index.test.tsx | 57 +++-- frontend/src/features/account-panel/index.tsx | 70 +++--- .../src/features/auth-session/index.test.tsx | 1 - frontend/src/features/quota/index.test.ts | 14 -- frontend/src/pages/account/index.test.tsx | 205 ------------------ frontend/src/pages/account/index.tsx | 153 +------------ frontend/src/pages/landing/index.test.tsx | 12 +- .../src/pages/landing/marketing-header.tsx | 24 ++ 18 files changed, 115 insertions(+), 557 deletions(-) diff --git a/frontend/src/app/layout/app-header.test.tsx b/frontend/src/app/layout/app-header.test.tsx index b3eb07e7..39088912 100644 --- a/frontend/src/app/layout/app-header.test.tsx +++ b/frontend/src/app/layout/app-header.test.tsx @@ -47,26 +47,10 @@ const creditAccount: CreditAccount = { function createQuotaMock(): QuotaApis & { getBalance: ReturnType listTransactions: ReturnType - getInviteCode: ReturnType - generateInviteCode: ReturnType - redeemInviteCode: ReturnType } { return { getBalance: vi.fn(async () => creditAccount), listTransactions: vi.fn(async () => ({ items: [], total: 0, page: 1, pageSize: 20 })), - getInviteCode: vi.fn(async () => ({ - code: 'AB23CD45', - usedCount: 0, - createdAt: '2026-08-12T01:02:03Z', - updatedAt: '2026-08-17T01:02:03Z', - })), - generateInviteCode: vi.fn(async () => ({ - code: 'XY89KL23', - usedCount: 0, - createdAt: '2026-08-17T03:00:00Z', - updatedAt: '2026-08-17T03:00:00Z', - })), - redeemInviteCode: vi.fn(async () => undefined), } } @@ -219,7 +203,7 @@ describe('AppHeader', () => { it('为访客提供可发现的登录入口并保留完整站内回跳地址', async () => { renderHeader('/quick-start?mode=fast#brief') - const entry = await screen.findByRole('link', { name: '登录 / 注册' }) + const entry = await screen.findByRole('link', { name: '登录' }) expect(entry.getAttribute('href')).toBe( '/?account=login&returnTo=%2Fquick-start%3Fmode%3Dfast%23brief', ) @@ -235,7 +219,7 @@ describe('AppHeader', () => { fireEvent.click(screen.getByRole('button', { name: '退出登录' })) await waitFor(() => expect(screen.getByTestId('location').textContent).toBe('/')) - expect(await screen.findByRole('link', { name: '登录 / 注册' })).toBeTruthy() + expect(await screen.findByRole('link', { name: '登录' })).toBeTruthy() expect(apis.logout).toHaveBeenCalledWith('rotated-refresh-token') }) @@ -249,7 +233,7 @@ describe('AppHeader', () => { fireEvent.click(screen.getByRole('button', { name: '退出登录' })) await waitFor(() => expect(screen.getByTestId('location').textContent).toBe('/')) - expect(await screen.findByRole('link', { name: '登录 / 注册' })).toBeTruthy() + expect(await screen.findByRole('link', { name: '登录' })).toBeTruthy() }) it('没有昵称时使用邮箱展示账号身份', async () => { diff --git a/frontend/src/app/layout/app-header.tsx b/frontend/src/app/layout/app-header.tsx index 43263b27..357340fd 100644 --- a/frontend/src/app/layout/app-header.tsx +++ b/frontend/src/app/layout/app-header.tsx @@ -201,10 +201,11 @@ export function AppHeader({ quotaApis = defaultQuotaApis }: AppHeaderProps = {}) ) : session.state.status === 'guest' ? ( - 登录 / 注册 + {/* 内测关闭公开注册。重新开放时改回「登录 / 注册」。 */} + 登录 登录 ) : ( diff --git a/frontend/src/entities/index.ts b/frontend/src/entities/index.ts index 405cafc6..6ffe5f88 100644 --- a/frontend/src/entities/index.ts +++ b/frontend/src/entities/index.ts @@ -10,7 +10,6 @@ export type { CreateQuotaApisOptions, CreditAccount, CreditTransaction, - InviteCode, QuotaApis, QuotaTransactionPageQuery, } from './quota' diff --git a/frontend/src/entities/quota/api.test.ts b/frontend/src/entities/quota/api.test.ts index d602de0b..7180b6b6 100644 --- a/frontend/src/entities/quota/api.test.ts +++ b/frontend/src/entities/quota/api.test.ts @@ -88,46 +88,6 @@ describe('createQuotaApis', () => { }) }) - it('读取并映射当前用户邀请码', async () => { - request.mockResolvedValue({ - code: 'AB23CD45', - used_count: 3, - create_at: '2026-08-12T01:02:03Z', - update_at: '2026-08-17T01:02:03Z', - }) - - await expect(createQuotaApis({ client }).getInviteCode()).resolves.toEqual({ - code: 'AB23CD45', - usedCount: 3, - createdAt: '2026-08-12T01:02:03Z', - updatedAt: '2026-08-17T01:02:03Z', - }) - expect(request).toHaveBeenCalledWith('/quota/invite/code') - }) - - it('轮换邀请码并提交兑换请求', async () => { - request - .mockResolvedValueOnce({ - code: 'XY89KL23', - used_count: 0, - create_at: '2026-08-17T03:00:00Z', - update_at: '2026-08-17T03:00:00Z', - }) - .mockResolvedValueOnce(null) - - const apis = createQuotaApis({ client }) - await expect(apis.generateInviteCode()).resolves.toMatchObject({ - code: 'XY89KL23', - usedCount: 0, - }) - await expect(apis.redeemInviteCode('ab23cd45')).resolves.toBeUndefined() - expect(request).toHaveBeenNthCalledWith(1, '/quota/invite/generate', { method: 'POST' }) - expect(request).toHaveBeenNthCalledWith(2, '/quota/invite/redeem', { - method: 'POST', - json: { code: 'ab23cd45' }, - }) - }) - it('默认适配器读取环境地址并携带当前登录凭证', async () => { vi.resetModules() const fetchFn = vi.fn(async (input) => { @@ -141,18 +101,7 @@ describe('createQuotaApis', () => { page: 1, page_size: 20, } - : url.includes('/quota/invite') - ? { - code: 200, - message: 'ok', - data: { - code: 'AB23CD45', - used_count: 0, - create_at: '2026-08-12T01:02:03Z', - update_at: '2026-08-17T01:02:03Z', - }, - } - : { code: 200, message: 'ok', data: accountResponse } + : { code: 200, message: 'ok', data: accountResponse } return Promise.resolve(new Response(JSON.stringify(body), { status: 200 })) }) vi.stubEnv('VITE_API_BASE_URL', 'https://api.windup.test') @@ -169,9 +118,6 @@ describe('createQuotaApis', () => { items: [], total: 0, }) - await expect(quotaApis.getInviteCode()).resolves.toMatchObject({ code: 'AB23CD45' }) - await expect(quotaApis.generateInviteCode()).resolves.toMatchObject({ code: 'AB23CD45' }) - await expect(quotaApis.redeemInviteCode('AB23CD45')).resolves.toBeUndefined() expect(fetchFn).toHaveBeenCalledWith( 'https://api.windup.test/quota/balance', expect.objectContaining({ diff --git a/frontend/src/entities/quota/api.ts b/frontend/src/entities/quota/api.ts index 1b1529c5..6c7c3014 100644 --- a/frontend/src/entities/quota/api.ts +++ b/frontend/src/entities/quota/api.ts @@ -1,7 +1,6 @@ import type { CreditAccount, CreditTransaction, - InviteCode, QuotaApis, QuotaTransactionPageQuery, } from './types' @@ -31,13 +30,6 @@ interface CreditTransactionDto { create_at: string } -interface InviteCodeDto { - code: string - used_count: number - create_at: string - update_at: string -} - export interface CreateQuotaApisOptions extends ApiClientOptions { client?: ApiClient } @@ -68,15 +60,6 @@ function toCreditTransaction(dto: CreditTransactionDto): CreditTransaction { } } -function toInviteCode(dto: InviteCodeDto): InviteCode { - return { - code: dto.code, - usedCount: dto.used_count, - createdAt: dto.create_at, - updatedAt: dto.update_at, - } -} - export function createQuotaApis(options: CreateQuotaApisOptions = {}): QuotaApis { const { client, ...clientOptions } = options const protectedClient = @@ -99,20 +82,6 @@ export function createQuotaApis(options: CreateQuotaApisOptions = {}): QuotaApis ) return { ...result, items: result.items.map(toCreditTransaction) } }, - async getInviteCode() { - return toInviteCode(await protectedClient.request('/quota/invite/code')) - }, - async generateInviteCode() { - return toInviteCode( - await protectedClient.request('/quota/invite/generate', { method: 'POST' }), - ) - }, - async redeemInviteCode(code: string) { - await protectedClient.request('/quota/invite/redeem', { - method: 'POST', - json: { code }, - }) - }, } } @@ -127,7 +96,4 @@ function getDefaultApis(): QuotaApis { export const quotaApis: QuotaApis = { getBalance: () => getDefaultApis().getBalance(), listTransactions: (query) => getDefaultApis().listTransactions(query), - getInviteCode: () => getDefaultApis().getInviteCode(), - generateInviteCode: () => getDefaultApis().generateInviteCode(), - redeemInviteCode: (code) => getDefaultApis().redeemInviteCode(code), } diff --git a/frontend/src/entities/quota/index.ts b/frontend/src/entities/quota/index.ts index 7bf570a2..851076b0 100644 --- a/frontend/src/entities/quota/index.ts +++ b/frontend/src/entities/quota/index.ts @@ -3,7 +3,6 @@ export type { CreateQuotaApisOptions } from './api' export type { CreditAccount, CreditTransaction, - InviteCode, QuotaApis, QuotaTransactionPageQuery, } from './types' diff --git a/frontend/src/entities/quota/types.ts b/frontend/src/entities/quota/types.ts index bd45f00e..049b73b5 100644 --- a/frontend/src/entities/quota/types.ts +++ b/frontend/src/entities/quota/types.ts @@ -26,18 +26,7 @@ export interface CreditTransaction { export type QuotaTransactionPageQuery = PageQuery -/** 当前登录用户的邀请码;usedCount 为已被成功兑换的次数。 */ -export interface InviteCode { - code: string - usedCount: number - createdAt: string - updatedAt: string -} - export interface QuotaApis { getBalance(): Promise listTransactions(query?: QuotaTransactionPageQuery): Promise> - getInviteCode(): Promise - generateInviteCode(): Promise - redeemInviteCode(code: string): Promise } diff --git a/frontend/src/entities/user/api.test.ts b/frontend/src/entities/user/api.test.ts index ec5068f0..5864c352 100644 --- a/frontend/src/entities/user/api.test.ts +++ b/frontend/src/entities/user/api.test.ts @@ -52,7 +52,6 @@ describe('createUserApis', () => { password: 'password-123', code: '123456', nickname: 'Reader', - inviteCode: 'AB23CD45', }) await apis.login({ email: 'reader@example.com', @@ -80,7 +79,6 @@ describe('createUserApis', () => { email: 'reader@example.com', password: 'password-123', code: '123456', - invite_code: 'AB23CD45', nickname: 'Reader', }, }, @@ -187,7 +185,6 @@ describe('createUserApis', () => { password: 'password-123', code: '123456', nickname: '', - inviteCode: 'AB23CD45', }) expect(request).toHaveBeenCalledWith('/auth/register', { @@ -196,7 +193,6 @@ describe('createUserApis', () => { email: 'reader@example.com', password: 'password-123', code: '123456', - invite_code: 'AB23CD45', }, }) }) diff --git a/frontend/src/entities/user/api.ts b/frontend/src/entities/user/api.ts index ed4cf724..f89e7809 100644 --- a/frontend/src/entities/user/api.ts +++ b/frontend/src/entities/user/api.ts @@ -96,7 +96,6 @@ export function createUserApis(options: CreateUserApisOptions = {}): UserApis { email: input.email, password: input.password, code: input.code, - invite_code: input.inviteCode, ...(input.nickname ? { nickname: input.nickname } : {}), } return toAuthTokens( diff --git a/frontend/src/entities/user/index.ts b/frontend/src/entities/user/index.ts index b5ce0839..f19e0556 100644 --- a/frontend/src/entities/user/index.ts +++ b/frontend/src/entities/user/index.ts @@ -24,7 +24,6 @@ export interface UserApis { password: string code: string nickname?: string - inviteCode: string }): Promise /** 密码登录不带验证码;验证码只用于注册、免密登录与重设密码。 */ login(input: { email: string; password: string }): Promise diff --git a/frontend/src/features/account-panel/index.test.tsx b/frontend/src/features/account-panel/index.test.tsx index 149d3bf1..ddbe36ad 100644 --- a/frontend/src/features/account-panel/index.test.tsx +++ b/frontend/src/features/account-panel/index.test.tsx @@ -94,13 +94,31 @@ describe('AccountPanel', () => { expect(screen.getByRole('dialog', { name: '登录 Windup' })).toBeTruthy() expect(screen.getByRole('heading', { name: '欢迎回来。' })).toBeTruthy() - expect(screen.getByText('没有账号请先用邀请码注册。')).toBeTruthy() + expect(screen.queryByText('未注册的邮箱将在验证后自动创建账号。')).toBeNull() + expect(screen.getByText('内测期间仅支持已有账号登录。')).toBeTruthy() expect(screen.queryByRole('tab', { name: '注册' })).toBeNull() - expect(screen.getByRole('button', { name: '创建账号' })).toBeTruthy() + expect(screen.queryByRole('button', { name: '创建账号' })).toBeNull() + expect(screen.getByRole('link', { name: 'GitHub Issues' }).getAttribute('href')).toBe( + 'https://github.com/1024XEngineer/Windup/issues', + ) await waitFor(() => expect(document.activeElement).toBe(screen.getByLabelText('邮箱'))) }) - it('opens registration as a centered, progressive form', async () => { + it('opens a closed-registration URL as login and never starts signup', async () => { + const { apis } = renderPanel('/?account=register&returnTo=%2Fworkspace') + + expect(screen.getByRole('dialog', { name: '登录 Windup' })).toBeTruthy() + expect(screen.getByRole('heading', { name: '欢迎回来。' })).toBeTruthy() + expect(screen.queryByRole('button', { name: '创建账号' })).toBeNull() + expect(screen.queryByRole('button', { name: '继续' })).toBeNull() + expect(screen.getByRole('button', { name: '登录' })).toBeTruthy() + expect(screen.getByText(/内测期间暂不开放注册/)).toBeTruthy() + expect(apis.register).not.toHaveBeenCalled() + await waitFor(() => expect(document.activeElement).toBe(screen.getByLabelText('邮箱'))) + }) + + // 内测关闭公开注册。重新开放时取消 skip,并恢复 AccountPanel 的 register 入口。 + it.skip('opens registration as a centered, progressive form', async () => { renderPanel('/?account=register&returnTo=%2Fworkspace') const dialog = screen.getByRole('dialog', { name: '创建 Windup 账号' }) @@ -110,7 +128,6 @@ describe('AccountPanel', () => { expect(screen.queryByText('继续搭建,')).toBeNull() expect(screen.getByTestId('register-fields').className).toContain('auth-register-fields') expect(screen.queryByRole('tablist', { name: '账号操作' })).toBeNull() - expect(screen.getByLabelText('邀请码')).toBeTruthy() expect(screen.getByLabelText('邮箱')).toBeTruthy() expect(screen.queryByLabelText('密码')).toBeNull() expect(screen.queryByLabelText('昵称(选填)')).toBeNull() @@ -142,15 +159,15 @@ describe('AccountPanel', () => { const dialog = screen.getByRole('dialog', { name: '登录 Windup' }) const closeButton = screen.getByRole('button', { name: '关闭账号面板' }) - const entrySwitch = screen.getByRole('button', { name: '创建账号' }) + const requestAccess = screen.getByRole('link', { name: 'GitHub Issues' }) - entrySwitch.focus() - fireEvent.keyDown(entrySwitch, { key: 'Tab' }) + requestAccess.focus() + fireEvent.keyDown(requestAccess, { key: 'Tab' }) expect(document.activeElement).toBe(closeButton) closeButton.focus() fireEvent.keyDown(closeButton, { key: 'Tab', shiftKey: true }) - expect(document.activeElement).toBe(entrySwitch) + expect(document.activeElement).toBe(requestAccess) expect(dialog.contains(document.activeElement)).toBe(true) }) @@ -347,9 +364,20 @@ describe('AccountPanel', () => { expect(apis.sendCode).not.toHaveBeenCalled() }) - it('preserves registration input when showing a password and returning a step', async () => { + it('does not expose a signup switch from the login panel', async () => { + vi.useFakeTimers() + renderPanel('/?account=login&returnTo=%2Fworkspace') + + expect(screen.queryByRole('button', { name: '创建账号' })).toBeNull() + expect(screen.getByTestId('location').textContent).toBe('/?account=login&returnTo=%2Fworkspace') + + await act(async () => vi.advanceTimersByTimeAsync(520)) + expect(screen.getByRole('dialog', { name: '登录 Windup' })).toBeTruthy() + expect(screen.getByTestId('location').textContent).toBe('/?account=login&returnTo=%2Fworkspace') + }) + + it.skip('preserves registration input when showing a password and returning a step', async () => { renderPanel('/?account=register') - fireEvent.change(screen.getByLabelText('邀请码'), { target: { value: 'AB23CD45' } }) fireEvent.change(screen.getByLabelText('邮箱'), { target: { value: 'new@example.com' } }) fireEvent.submit(screen.getByRole('button', { name: '继续' }).closest('form')!) @@ -362,11 +390,10 @@ describe('AccountPanel', () => { expect(((await screen.findByLabelText('邮箱')) as HTMLInputElement).value).toBe( 'new@example.com', ) - expect(((await screen.findByLabelText('邀请码')) as HTMLInputElement).value).toBe('AB23CD45') expect(screen.getByTestId('auth-motion-stage').dataset.motionDirection).toBe('backward') }) - it('switches account entry only after the current panel exits', async () => { + it.skip('switches account entry only after the current panel exits', async () => { vi.useFakeTimers() renderPanel('/?account=login&returnTo=%2Fworkspace') @@ -381,13 +408,10 @@ describe('AccountPanel', () => { ) }) - it('validates each registration step and reuses the existing register API contract', async () => { + it.skip('validates each registration step and reuses the existing register API contract', async () => { const { apis } = renderPanel('/?account=register&returnTo=%2Fworkspace') fireEvent.change(screen.getByLabelText('邮箱'), { target: { value: 'new@example.com' } }) - fireEvent.submit(screen.getByRole('button', { name: '继续' }).closest('form')!) - expect((await screen.findByRole('alert')).textContent).toContain('请填写有效邀请码') - fireEvent.change(screen.getByLabelText('邀请码'), { target: { value: 'AB23CD45' } }) fireEvent.submit(screen.getByRole('button', { name: '继续' }).closest('form')!) expect(screen.getByRole('dialog', { name: '创建 Windup 账号' })).toBeTruthy() expect(screen.getByRole('heading', { name: '为账号加一道保护' })).toBeTruthy() @@ -428,7 +452,6 @@ describe('AccountPanel', () => { email: 'new@example.com', password: 'password-123', code: '123456', - inviteCode: 'AB23CD45', nickname: '新用户', }), ) diff --git a/frontend/src/features/account-panel/index.tsx b/frontend/src/features/account-panel/index.tsx index 21a457e0..118043ce 100644 --- a/frontend/src/features/account-panel/index.tsx +++ b/frontend/src/features/account-panel/index.tsx @@ -19,7 +19,6 @@ import { EyeClosed, Keyhole, SealCheck, - Ticket, UserCircle, X, type Icon, @@ -92,6 +91,7 @@ const loginMotionCopy = [ const REGISTER_STEP_COUNT = 4 const AUTH_ICON_PROPS = { weight: 'light' as const } const AUTH_FIELD_CLASS = 'auth-screen-field w-full outline-none disabled:cursor-not-allowed' +const ACCESS_REQUEST_URL = 'https://github.com/1024XEngineer/Windup/issues' function errorMessage(error: unknown): string { return error instanceof Error && error.message ? error.message : '操作失败,请稍后重试' @@ -201,7 +201,9 @@ export function AccountPanel() { const entry = searchParams.get('account') if (entry !== 'login' && entry !== 'register') return null - return + // 内测期间关闭公开注册。重新开放时改回: + // return + return } /** 只有面板真正打开时才读取会话,关闭状态不把认证 Context 强加给应用外壳。 */ @@ -215,7 +217,6 @@ function AccountPanelDialog({ entry }: { entry: AccountEntry }) { const [showPassword, setShowPassword] = useState(false) const [code, setCode] = useState('') const [nickname, setNickname] = useState('') - const [inviteCode, setInviteCode] = useState('') const [registerStep, setRegisterStep] = useState(0) const [motionDirection, setMotionDirection] = useState('forward') const [copyIndex, setCopyIndex] = useState(0) @@ -346,6 +347,7 @@ function AccountPanelDialog({ entry }: { entry: AccountEntry }) { window.requestAnimationFrame(() => emailInputRef.current?.focus()) } + /* function switchEntry(nextEntry: AccountEntry) { leaveWithAnimation(() => { const next = new URLSearchParams(searchParams) @@ -353,6 +355,7 @@ function AccountPanelDialog({ entry }: { entry: AccountEntry }) { setSearchParams(next, { replace: true }) }) } + */ async function sendCode(): Promise { if (isSendingCode || cooldownSeconds > 0) return false @@ -393,7 +396,6 @@ function AccountPanelDialog({ entry }: { entry: AccountEntry }) { function validateRegistration(): string | null { if (!EMAIL_PATTERN.test(normalizedEmail)) return '请输入有效邮箱地址' - if (!/^[A-HJ-NP-Z2-9]{4,16}$/i.test(inviteCode.trim())) return '请填写有效邀请码' if (password.length < 8 || password.length > 128) return '密码需为 8–128 位' if (nickname.length > 50) return '昵称不能超过 50 个字符' if (!CODE_PATTERN.test(code)) return '验证码需为 6 位数字' @@ -404,8 +406,6 @@ function AccountPanelDialog({ entry }: { entry: AccountEntry }) { let validationError: string | null = null if (registerStep === 0 && !EMAIL_PATTERN.test(normalizedEmail)) { validationError = '请输入有效邮箱地址' - } else if (registerStep === 0 && !/^[A-HJ-NP-Z2-9]{4,16}$/i.test(inviteCode.trim())) { - validationError = '请填写有效邀请码' } else if (registerStep === 1 && (password.length < 8 || password.length > 128)) { validationError = '密码需为 8–128 位' } else if (registerStep === 2 && nickname.length > 50) { @@ -458,12 +458,13 @@ function AccountPanelDialog({ entry }: { entry: AccountEntry }) { email: normalizedEmail, password, code, - inviteCode: inviteCode.trim().toUpperCase(), ...(nickname.trim() ? { nickname: nickname.trim() } : {}), }) successMessage = '账号已创建,正在继续。' } else if (mode === 'code') { await session.loginByCode({ email: normalizedEmail, code }) + // 内测不自动建号。重新开放注册时改回: + // successMessage = '登录成功。如果这是你首次使用该邮箱,我们已为你创建账号。' successMessage = '登录成功,正在继续。' } else { await session.login({ email: normalizedEmail, password }) @@ -669,34 +670,19 @@ function AccountPanelDialog({ entry }: { entry: AccountEntry }) { className={`auth-motion-stage auth-motion-stage-${motionDirection}`} > {(!isRegister || registerStep === 0) && ( - <> - {isRegister && ( - - )} - - + )} {((isRegister && registerStep === 1) || (!isRegister && mode === 'password')) && ( @@ -770,7 +756,10 @@ function AccountPanelDialog({ entry }: { entry: AccountEntry }) { )} {!isRegister && mode === 'code' && ( -

没有账号请先用邀请码注册。

+

+ {/* 未注册的邮箱将在验证后自动创建账号。 */} + 内测期间仅支持已有账号登录。 +

)} @@ -795,12 +784,21 @@ function AccountPanelDialog({ entry }: { entry: AccountEntry }) { + {/*

{isRegister ? '已有账号?' : '还没有账号?'}{' '}

+ */} +

+ 内测期间暂不开放注册。如需开通,请通过{' '} + + GitHub Issues + {' '} + 联系团队申请。 +

diff --git a/frontend/src/features/auth-session/index.test.tsx b/frontend/src/features/auth-session/index.test.tsx index 126b7b6a..1e0d8237 100644 --- a/frontend/src/features/auth-session/index.test.tsx +++ b/frontend/src/features/auth-session/index.test.tsx @@ -145,7 +145,6 @@ describe('AuthSessionProvider', () => { email: 'reader@example.com', password: 'password-123', code: '123456', - inviteCode: 'AB23CD45', }) } else if (method === 'login') { await session().login({ diff --git a/frontend/src/features/quota/index.test.ts b/frontend/src/features/quota/index.test.ts index c1dd123c..c7c29e72 100644 --- a/frontend/src/features/quota/index.test.ts +++ b/frontend/src/features/quota/index.test.ts @@ -45,19 +45,6 @@ function createQuotaApis(): QuotaApis & { page, pageSize, })), - getInviteCode: vi.fn(async () => ({ - code: 'AB23CD45', - usedCount: 0, - createdAt: '2026-08-12T01:02:03Z', - updatedAt: '2026-08-17T01:02:03Z', - })), - generateInviteCode: vi.fn(async () => ({ - code: 'XY89KL23', - usedCount: 0, - createdAt: '2026-08-17T03:00:00Z', - updatedAt: '2026-08-17T03:00:00Z', - })), - redeemInviteCode: vi.fn(async () => undefined), } } @@ -131,7 +118,6 @@ describe('quota queries', () => { }) it('为已知和未知原因码提供文案,并处理无效时间', () => { - expect(getCreditReasonLabel(2)).toBe('邀请奖励') expect(getCreditReasonLabel(4)).toBe('生成角色动作') expect(getCreditReasonLabel(99)).toBe('积分变动(原因码 99)') expect(formatCreditDateTime('not-a-date')).toBe('时间未知') diff --git a/frontend/src/pages/account/index.test.tsx b/frontend/src/pages/account/index.test.tsx index 480f0ad6..286423b0 100644 --- a/frontend/src/pages/account/index.test.tsx +++ b/frontend/src/pages/account/index.test.tsx @@ -30,25 +30,6 @@ function deferred() { return { promise, resolve, reject } } -function mockQuotaReads() { - vi.spyOn(quotaApis, 'getBalance').mockResolvedValue({ - id: '11', - userId: '7', - balance: 90, - frozen: 10, - totalEarned: 150, - totalSpent: 50, - createdAt: '2026-08-12T01:02:03Z', - updatedAt: '2026-08-17T01:02:03Z', - }) - vi.spyOn(quotaApis, 'listTransactions').mockResolvedValue({ - items: [], - total: 0, - page: 1, - pageSize: 20, - }) -} - function createApis(): UserApis & Record> { return { sendCode: vi.fn(async () => undefined), @@ -198,12 +179,6 @@ describe('AccountPage', () => { page: 1, pageSize: 20, }) - vi.spyOn(quotaApis, 'getInviteCode').mockResolvedValue({ - code: 'AB23CD45', - usedCount: 0, - createdAt: '2026-08-12T01:02:03Z', - updatedAt: '2026-08-17T01:02:03Z', - }) renderAccount() fireEvent.click(await screen.findByRole('button', { name: '积分账户' })) @@ -214,186 +189,6 @@ describe('AccountPage', () => { expect(screen.getByText('-12')).toBeTruthy() }) - it('在积分账户展示邀请码,并允许重新生成与补填', async () => { - vi.spyOn(quotaApis, 'getBalance').mockResolvedValue({ - id: '11', - userId: '7', - balance: 90, - frozen: 10, - totalEarned: 150, - totalSpent: 50, - createdAt: '2026-08-12T01:02:03Z', - updatedAt: '2026-08-17T01:02:03Z', - }) - vi.spyOn(quotaApis, 'listTransactions').mockResolvedValue({ - items: [], - total: 0, - page: 1, - pageSize: 20, - }) - vi.spyOn(quotaApis, 'getInviteCode').mockResolvedValue({ - code: 'AB23CD45', - usedCount: 2, - createdAt: '2026-08-12T01:02:03Z', - updatedAt: '2026-08-17T01:02:03Z', - }) - vi.spyOn(quotaApis, 'generateInviteCode').mockResolvedValue({ - code: 'XY89KL23', - usedCount: 0, - createdAt: '2026-08-17T03:00:00Z', - updatedAt: '2026-08-17T03:00:00Z', - }) - const redeem = vi.spyOn(quotaApis, 'redeemInviteCode').mockResolvedValue(undefined) - - renderAccount() - fireEvent.click(await screen.findByRole('button', { name: '积分账户' })) - - expect(await screen.findByText('AB23CD45')).toBeTruthy() - expect(screen.getByText('已邀请 2 人')).toBeTruthy() - - fireEvent.click(screen.getByRole('button', { name: '重新生成' })) - expect(await screen.findByText('XY89KL23')).toBeTruthy() - expect(await screen.findByText('邀请码已更新')).toBeTruthy() - - fireEvent.change(screen.getByLabelText('填写邀请码'), { target: { value: 'nope' } }) - fireEvent.click(screen.getByRole('button', { name: '确认填写' })) - expect(await screen.findByText('请填写有效邀请码')).toBeTruthy() - expect(redeem).not.toHaveBeenCalled() - - fireEvent.change(screen.getByLabelText('填写邀请码'), { target: { value: 'mn67pq89' } }) - fireEvent.click(screen.getByRole('button', { name: '确认填写' })) - await waitFor(() => expect(redeem).toHaveBeenCalledWith('MN67PQ89')) - expect(await screen.findByText('邀请码填写成功')).toBeTruthy() - }) - - it('兑换成功后刷新积分余额与流水', async () => { - const getBalance = vi - .spyOn(quotaApis, 'getBalance') - .mockResolvedValueOnce({ - id: '11', - userId: '7', - balance: 90, - frozen: 10, - totalEarned: 150, - totalSpent: 50, - createdAt: '2026-08-12T01:02:03Z', - updatedAt: '2026-08-17T01:02:03Z', - }) - .mockResolvedValue({ - id: '11', - userId: '7', - balance: 140, - frozen: 10, - totalEarned: 200, - totalSpent: 50, - createdAt: '2026-08-12T01:02:03Z', - updatedAt: '2026-08-17T03:00:00Z', - }) - const listTransactions = vi - .spyOn(quotaApis, 'listTransactions') - .mockResolvedValueOnce({ - items: [], - total: 0, - page: 1, - pageSize: 20, - }) - .mockResolvedValue({ - items: [ - { - id: '22', - userId: '7', - delta: 50, - reason: 2, - billingMode: 0, - refId: 'invite:7:invitee', - balanceAfter: 140, - createdAt: '2026-08-17T03:00:00Z', - }, - ], - total: 1, - page: 1, - pageSize: 20, - }) - vi.spyOn(quotaApis, 'getInviteCode').mockResolvedValue({ - code: 'AB23CD45', - usedCount: 0, - createdAt: '2026-08-12T01:02:03Z', - updatedAt: '2026-08-17T01:02:03Z', - }) - vi.spyOn(quotaApis, 'redeemInviteCode').mockResolvedValue(undefined) - - renderAccount() - fireEvent.click(await screen.findByRole('button', { name: '积分账户' })) - expect(await screen.findByText('90')).toBeTruthy() - - fireEvent.change(screen.getByLabelText('填写邀请码'), { target: { value: 'MN67PQ89' } }) - fireEvent.click(screen.getByRole('button', { name: '确认填写' })) - - expect(await screen.findByText('邀请码填写成功')).toBeTruthy() - await waitFor(() => expect(getBalance).toHaveBeenCalledTimes(2)) - expect(listTransactions).toHaveBeenCalledTimes(2) - expect(await screen.findByText('140')).toBeTruthy() - expect(screen.getByText('邀请奖励')).toBeTruthy() - }) - - it('展示邀请码加载失败', async () => { - mockQuotaReads() - vi.spyOn(quotaApis, 'getInviteCode').mockRejectedValue(new Error('邀请码服务不可用')) - - renderAccount() - fireEvent.click(await screen.findByRole('button', { name: '积分账户' })) - - expect(await screen.findByText('邀请码服务不可用')).toBeTruthy() - expect(screen.queryByText('正在加载邀请码…')).toBeNull() - expect(screen.queryByRole('button', { name: '重新生成' })).toBeNull() - }) - - it('重新生成或兑换失败时提示错误', async () => { - mockQuotaReads() - vi.spyOn(quotaApis, 'getInviteCode').mockResolvedValue({ - code: 'AB23CD45', - usedCount: 0, - createdAt: '2026-08-12T01:02:03Z', - updatedAt: '2026-08-17T01:02:03Z', - }) - vi.spyOn(quotaApis, 'generateInviteCode').mockRejectedValue(new Error('邀请码已用尽')) - vi.spyOn(quotaApis, 'redeemInviteCode').mockRejectedValue('兑换被拒绝') - - renderAccount() - fireEvent.click(await screen.findByRole('button', { name: '积分账户' })) - expect(await screen.findByText('AB23CD45')).toBeTruthy() - - fireEvent.click(screen.getByRole('button', { name: '重新生成' })) - expect(await screen.findByText('邀请码已用尽')).toBeTruthy() - - fireEvent.change(screen.getByLabelText('填写邀请码'), { target: { value: 'MN67PQ89' } }) - fireEvent.click(screen.getByRole('button', { name: '确认填写' })) - expect(await screen.findByText('操作失败,请稍后重试')).toBeTruthy() - }) - - it('邀请码加载中忽略卸载后的迟到结果', async () => { - mockQuotaReads() - const pending = deferred<{ - code: string - usedCount: number - createdAt: string - updatedAt: string - }>() - vi.spyOn(quotaApis, 'getInviteCode').mockReturnValue(pending.promise) - - const { unmount } = renderAccount() - fireEvent.click(await screen.findByRole('button', { name: '积分账户' })) - expect(await screen.findByText('正在加载邀请码…')).toBeTruthy() - unmount() - pending.resolve({ - code: 'AB23CD45', - usedCount: 0, - createdAt: '2026-08-12T01:02:03Z', - updatedAt: '2026-08-17T01:02:03Z', - }) - await Promise.resolve() - }) - it('reports a profile refresh failure without claiming the data is synchronized', async () => { const apis = createApis() apis.me.mockRejectedValue(new Error('资料读取失败')) diff --git a/frontend/src/pages/account/index.tsx b/frontend/src/pages/account/index.tsx index 2158ccfb..65e8676f 100644 --- a/frontend/src/pages/account/index.tsx +++ b/frontend/src/pages/account/index.tsx @@ -1,8 +1,7 @@ import { useEffect, useId, useReducer, useRef, useState, type FormEvent } from 'react' import accountBadgeArtwork from '@/assets/account/illustrations/account-badge.webp' -import { quotaApis } from '@/entities' -import type { InviteCode, User } from '@/entities' +import type { User } from '@/entities' import { useAuthSession } from '@/features/auth-session' import { formatCreditDateTime, @@ -37,149 +36,6 @@ function formatCredits(value: number): string { return value.toLocaleString('zh-CN') } -const INVITE_CODE_PATTERN = /^[A-HJ-NP-Z2-9]{4,16}$/i - -function InviteSection({ onCreditsChanged }: { onCreditsChanged(): void }) { - const redeemId = useId() - const [invite, setInvite] = useState(null) - const [loadError, setLoadError] = useState(null) - const [actionError, setActionError] = useState(null) - const [success, setSuccess] = useState(null) - const [redeemCode, setRedeemCode] = useState('') - const [busy, setBusy] = useState(false) - - useEffect(() => { - let active = true - void quotaApis.getInviteCode().then( - (view) => { - if (!active) return - setInvite(view) - setLoadError(null) - }, - (error: unknown) => { - if (!active) return - setLoadError(errorMessage(error)) - }, - ) - return () => { - active = false - } - }, []) - - async function rotateCode() { - if (busy) return - setBusy(true) - setActionError(null) - setSuccess(null) - try { - const view = await quotaApis.generateInviteCode() - setInvite(view) - setSuccess('邀请码已更新') - } catch (error) { - setActionError(errorMessage(error)) - } finally { - setBusy(false) - } - } - - async function redeem(event: FormEvent) { - event.preventDefault() - if (busy) return - const normalized = redeemCode.trim().toUpperCase() - if (!INVITE_CODE_PATTERN.test(normalized)) { - setActionError('请填写有效邀请码') - setSuccess(null) - return - } - setBusy(true) - setActionError(null) - setSuccess(null) - try { - await quotaApis.redeemInviteCode(normalized) - setRedeemCode('') - setSuccess('邀请码填写成功') - onCreditsChanged() - } catch (error) { - setActionError(errorMessage(error)) - } finally { - setBusy(false) - } - } - - return ( -
-

- 邀请码 -

-

- 把你的邀请码发给朋友即可注册。已有账号也可以在这里补填一次邀请码。 -

- - {loadError ? ( -

- {loadError} -

- ) : invite ? ( -
-
-

- {invite.code} -

-

已邀请 {invite.usedCount} 人

-
- -
- ) : ( -

- 正在加载邀请码… -

- )} - -
- - {actionError && ( -

- {actionError} -

- )} - {success && ( -

- {success} -

- )} - -
-
- ) -} - function QuotaSection() { const balance = useQuotaBalance(true) const transactions = useQuotaTransactions(true) @@ -210,13 +66,6 @@ function QuotaSection() { ))} - { - balance.reload() - transactions.reload() - }} - /> - {balance.status === 'loading' && (

正在加载积分余额… diff --git a/frontend/src/pages/landing/index.test.tsx b/frontend/src/pages/landing/index.test.tsx index 3190cb52..7ec45855 100644 --- a/frontend/src/pages/landing/index.test.tsx +++ b/frontend/src/pages/landing/index.test.tsx @@ -56,9 +56,15 @@ describe('LandingPage', () => { expect(screen.getByRole('link', { name: '登录' }).getAttribute('href')).toBe( '/?account=login&returnTo=%2Fworkspace', ) - expect(screen.getByRole('link', { name: '注册' }).getAttribute('href')).toBe( - '/?account=register&returnTo=%2Fworkspace', - ) + expect((screen.getByRole('button', { name: '注册' }) as HTMLButtonElement).disabled).toBe(true) + expect(screen.queryByRole('link', { name: '注册' })).toBeNull() + expect( + screen.getByRole('link', { name: '内测暂不开放,联系团队申请' }).getAttribute('href'), + ).toBe('https://github.com/1024XEngineer/Windup/issues') + // 重新开放注册时恢复: + // expect(screen.getByRole('link', { name: '注册' }).getAttribute('href')).toBe( + // '/?account=register&returnTo=%2Fworkspace', + // ) // Header 只处理账号入口,Hero 与收尾负责把用户带进创作。 const creationLinks = screen.getAllByRole('link', { name: '开始创作' }) expect(creationLinks).toHaveLength(2) diff --git a/frontend/src/pages/landing/marketing-header.tsx b/frontend/src/pages/landing/marketing-header.tsx index 755aae9e..b14c0196 100644 --- a/frontend/src/pages/landing/marketing-header.tsx +++ b/frontend/src/pages/landing/marketing-header.tsx @@ -7,10 +7,14 @@ const loginEntry = `/?${new URLSearchParams({ returnTo: '/workspace', })}` +// 内测关闭公开注册。重新开放时恢复 registerEntry 与下方注册链接。 +const ACCESS_REQUEST_URL = 'https://github.com/1024XEngineer/Windup/issues' +/* const registerEntry = `/?${new URLSearchParams({ account: 'register', returnTo: '/workspace', })}` +*/ const sections = [ ['#capabilities', '产品能力'], @@ -77,12 +81,32 @@ export function MarketingHeader() { > 登录 + {/* 注册 + */} + + + + 内测暂不开放,联系团队申请 + + )} From 46004490eb749f47dd9688e6b67b97e5c164e86f Mon Sep 17 00:00:00 2001 From: xiaocheny214 <187097481+xiaocheny214@users.noreply.github.com> Date: Tue, 18 Aug 2026 10:38:51 +0800 Subject: [PATCH 05/10] =?UTF-8?q?fix(quota):=20=E8=BD=AE=E6=8D=A2=E9=82=80?= =?UTF-8?q?=E8=AF=B7=E7=A0=81=E5=8A=A0=E8=A1=8C=E9=94=81=EF=BC=8C=E9=87=8D?= =?UTF-8?q?=E5=A4=8D=E5=85=91=E6=8D=A2=E6=94=B9=E4=B8=BA=E4=B8=9A=E5=8A=A1?= =?UTF-8?q?=E9=94=99=E8=AF=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 冷启动不加兑换次数上限。并发轮换用 FOR UPDATE 串行化;invitee 唯一约束冲突返回已填写过邀请码。邀请双方奖励默认改为 200,与账号中心 PR 对齐。 --- .env.example | 2 +- .../src/windup_app/server/quota/interface.py | 2 +- .../src/windup_app/server/quota/service.py | 17 +++++- .../app/src/windup_app/web/api/quota.py | 2 +- .../src/windup_framework/config/quota.py | 2 +- backend/tests/test_quota.py | 58 +++++++++++++++++++ openapi.json | 2 +- 7 files changed, 78 insertions(+), 7 deletions(-) diff --git a/.env.example b/.env.example index 8453460b..7797e0b6 100644 --- a/.env.example +++ b/.env.example @@ -41,7 +41,7 @@ AI_VIDEO_MODEL=kling-v2-5-turbo # ── 积分定价 ── QUOTA_REGISTER_GIFT_AMOUNT=100 -QUOTA_INVITE_REWARD_AMOUNT=50 +QUOTA_INVITE_REWARD_AMOUNT=200 QUOTA_GENERATE_IMAGE_COST=10 QUOTA_GENERATE_ACTION_COST=50 diff --git a/backend/packages/app/src/windup_app/server/quota/interface.py b/backend/packages/app/src/windup_app/server/quota/interface.py index 0201c36f..5c3f94ed 100644 --- a/backend/packages/app/src/windup_app/server/quota/interface.py +++ b/backend/packages/app/src/windup_app/server/quota/interface.py @@ -96,7 +96,7 @@ def get_invite_code(self, session: Session, user_id: int) -> InviteCodeView: @abstractmethod def generate_invite_code(self, session: Session, user_id: int) -> InviteCodeView: - """生成新邀请码(替换旧码)。""" + """生成新邀请码(替换旧码)。已有行会 FOR UPDATE,旧码立即失效。""" @abstractmethod def redeem_invite_code(self, session: Session, user_id: int, code: str) -> None: diff --git a/backend/packages/app/src/windup_app/server/quota/service.py b/backend/packages/app/src/windup_app/server/quota/service.py index 58f533f2..982ccf94 100644 --- a/backend/packages/app/src/windup_app/server/quota/service.py +++ b/backend/packages/app/src/windup_app/server/quota/service.py @@ -15,6 +15,7 @@ import secrets from sqlalchemy import func, select +from sqlalchemy.exc import IntegrityError from sqlalchemy.orm import Session from windup_common.enums.biz_code import BizCode @@ -48,6 +49,11 @@ def _new_invite_code() -> str: return "".join(secrets.choice(_INVITE_ALPHABET) for _ in range(_INVITE_CODE_LENGTH)) +def _is_invitee_unique_violation(exc: IntegrityError) -> bool: + text = f"{getattr(exc, 'orig', '')} {exc}".lower() + return "invitee" in text or "windup_invite_record" in text + + def _to_invite_view(row: InviteCode) -> InviteCodeView: return InviteCodeView( code=row.code, @@ -340,7 +346,9 @@ def generate_invite_code(self, session: Session, user_id: int) -> InviteCodeView if session.get(User, user_id) is None: raise BizException("用户不存在", code=BizCode.NOT_FOUND) - row = session.scalar(select(InviteCode).where(InviteCode.user_id == user_id)) + row = session.scalar( + select(InviteCode).where(InviteCode.user_id == user_id).with_for_update() + ) if row is None: row = InviteCode( user_id=user_id, code=self._allocate_invite_code(session), used_count=0 @@ -392,7 +400,12 @@ def redeem_invite_code(self, session: Session, user_id: int, code: str) -> None: ) session.add(record) invite.used_count += 1 - session.flush() + try: + session.flush() + except IntegrityError as exc: + if _is_invitee_unique_violation(exc): + raise BizException("已填写过邀请码", code=BizCode.BAD_REQUEST) from exc + raise reward = quota_settings.invite_reward_amount self.credit( diff --git a/backend/packages/app/src/windup_app/web/api/quota.py b/backend/packages/app/src/windup_app/web/api/quota.py index 05577048..c615d897 100644 --- a/backend/packages/app/src/windup_app/web/api/quota.py +++ b/backend/packages/app/src/windup_app/web/api/quota.py @@ -137,7 +137,7 @@ def generate_invite_code( request: Request, session: Session = Depends(get_session), ) -> Response[InviteCodeOut]: - """生成或轮换当前用户邀请码。""" + """生成或轮换当前用户邀请码。轮换后旧码立即失效。""" view = service.generate_invite_code(session, request.state.current_user.id) return Response.success( InviteCodeOut( diff --git a/backend/packages/framework/src/windup_framework/config/quota.py b/backend/packages/framework/src/windup_framework/config/quota.py index 3d648feb..57add5fb 100644 --- a/backend/packages/framework/src/windup_framework/config/quota.py +++ b/backend/packages/framework/src/windup_framework/config/quota.py @@ -21,7 +21,7 @@ class QuotaSettings(BaseSettings): # -- 注册 / 邀请 ------------------------------------------------------- register_gift_amount: int = 100 # 注册赠送积分 - invite_reward_amount: int = 50 # 邀请奖励(双方各得) + invite_reward_amount: int = 200 # 邀请奖励(双方各得) # -- 生成任务 ----------------------------------------------------------- generate_image_cost: int = 10 # 生成角色参考图 diff --git a/backend/tests/test_quota.py b/backend/tests/test_quota.py index b27bcb2b..e9afca85 100644 --- a/backend/tests/test_quota.py +++ b/backend/tests/test_quota.py @@ -531,6 +531,64 @@ def test_generate_invite_code_rotates(self, auth_quota_client): assert second != first assert len(second) == 8 + def test_generate_invite_code_locks_existing_row( + self, db_session, quota_service, monkeypatch + ): + from sqlalchemy.sql.selectable import Select + from windup_app.server.user.model import User + + host = User(email="lock-host@example.com", password_hash="x") + db_session.add(host) + db_session.flush() + quota_service.generate_invite_code(db_session, host.id) + + locked = [] + original = Select.with_for_update + + def tracking(self, *args, **kwargs): + locked.append(True) + return original(self, *args, **kwargs) + + monkeypatch.setattr(Select, "with_for_update", tracking) + quota_service.generate_invite_code(db_session, host.id) + assert locked, "轮换已有邀请码时应对该行加 FOR UPDATE" + + def test_redeem_unique_violation_is_already_redeemed( + self, db_session, quota_service, monkeypatch + ): + """并发双兑时 unique(invitee_id) 应收敛为「已填写过邀请码」,而不是 500。""" + from sqlalchemy.exc import IntegrityError + from windup_app.server.user.model import User + from windup_common.exceptions import BizException + + host = User(email="race-host@example.com", password_hash="x") + guest = User(email="race-guest@example.com", password_hash="x") + db_session.add_all([host, guest]) + db_session.flush() + _gift_account(db_session, host.id) + _gift_account(db_session, guest.id) + view = quota_service.generate_invite_code(db_session, host.id) + + from windup_app.server.quota.model import InviteRecord + + orig_flush = db_session.flush + + def boom(*_args, **_kwargs): + if any(isinstance(obj, InviteRecord) for obj in db_session.new): + raise IntegrityError( + "INSERT", + {}, + Exception( + "UNIQUE constraint failed: windup_invite_record.invitee_id" + ), + ) + return orig_flush(*_args, **_kwargs) + + monkeypatch.setattr(db_session, "flush", boom) + + with pytest.raises(BizException, match="已填写过邀请码"): + quota_service.redeem_invite_code(db_session, guest.id, view.code) + def test_redeem_invite_code_rewards_both_users(self, db_session, quota_service): from windup_app.server.user.model import User diff --git a/openapi.json b/openapi.json index ca788f0f..bf90a498 100644 --- a/openapi.json +++ b/openapi.json @@ -3227,7 +3227,7 @@ }, "/quota/invite/generate": { "post": { - "description": "生成或轮换当前用户邀请码。", + "description": "生成或轮换当前用户邀请码。轮换后旧码立即失效。", "operationId": "generate_invite_code_quota_invite_generate_post", "responses": { "200": { From f195085265bf98367b1b48b21aaf8260b7e1e473 Mon Sep 17 00:00:00 2001 From: xiaocheny214 <187097481+xiaocheny214@users.noreply.github.com> Date: Tue, 18 Aug 2026 10:54:36 +0800 Subject: [PATCH 06/10] =?UTF-8?q?fix(auth):=20=E9=82=80=E8=AF=B7=E7=A0=81?= =?UTF-8?q?=E6=A0=A1=E9=AA=8C=E5=AF=B9=E9=BD=90=E9=82=80=E8=AF=B7=E9=93=BE?= =?UTF-8?q?=E6=8E=A5=E5=AD=97=E7=AC=A6=E9=9B=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 注册不再返回「请填写邀请码」:前端从链接传入 invite_code,空码和非法字符统一为邀请码无效。 --- .../app/src/windup_app/server/quota/service.py | 14 +++++++++++--- .../app/src/windup_app/server/user/interface.py | 2 +- .../app/src/windup_app/server/user/model.py | 2 +- .../app/src/windup_app/server/user/service.py | 8 +++----- .../packages/app/src/windup_app/web/api/auth.py | 8 ++++++-- backend/tests/test_auth_registration_closed.py | 2 +- backend/tests/test_quota.py | 2 ++ backend/tests/test_user_service.py | 16 +++++++++++++++- openapi.json | 4 ++-- 9 files changed, 42 insertions(+), 16 deletions(-) diff --git a/backend/packages/app/src/windup_app/server/quota/service.py b/backend/packages/app/src/windup_app/server/quota/service.py index 982ccf94..6d056e51 100644 --- a/backend/packages/app/src/windup_app/server/quota/service.py +++ b/backend/packages/app/src/windup_app/server/quota/service.py @@ -12,6 +12,7 @@ """ import logging +import re import secrets from sqlalchemy import func, select @@ -39,12 +40,21 @@ _INVITE_ALPHABET = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789" _INVITE_CODE_LENGTH = 8 +_INVITE_CODE_RE = re.compile(rf"^[{re.escape(_INVITE_ALPHABET)}]{{4,16}}$") def normalize_invite_code(code: str) -> str: return code.strip().upper() +def parse_invite_code(code: str) -> str: + """解析邀请链接/补填传入的邀请码,字符集与前端 INVITE_CODE_PATTERN 一致。""" + normalized = normalize_invite_code(code) + if _INVITE_CODE_RE.fullmatch(normalized) is None: + raise BizException("邀请码无效", code=BizCode.BAD_REQUEST) + return normalized + + def _new_invite_code() -> str: return "".join(secrets.choice(_INVITE_ALPHABET) for _ in range(_INVITE_CODE_LENGTH)) @@ -373,9 +383,7 @@ def _allocate_invite_code( raise BizException("邀请码生成失败,请稍后重试", code=BizCode.BAD_REQUEST) def redeem_invite_code(self, session: Session, user_id: int, code: str) -> None: - normalized = normalize_invite_code(code) - if not normalized: - raise BizException("邀请码无效", code=BizCode.BAD_REQUEST) + normalized = parse_invite_code(code) existing = session.scalar( select(InviteRecord.id).where(InviteRecord.invitee_id == user_id) diff --git a/backend/packages/app/src/windup_app/server/user/interface.py b/backend/packages/app/src/windup_app/server/user/interface.py index e78b4521..bb6ffbc1 100644 --- a/backend/packages/app/src/windup_app/server/user/interface.py +++ b/backend/packages/app/src/windup_app/server/user/interface.py @@ -27,7 +27,7 @@ class UserService(ABC): @abstractmethod def register_by_email(self, session: Session, input: RegisterInput) -> LoginResult: - """邮箱+密码注册,须填写有效邀请码,注册成功即登录。 + """邮箱+验证码+密码注册。请求体须带邀请链接中的有效邀请码。 :raises windup_common.exceptions.BizException: 邮箱已注册 / 邀请码无效。 """ diff --git a/backend/packages/app/src/windup_app/server/user/model.py b/backend/packages/app/src/windup_app/server/user/model.py index 98ff047f..552e0139 100644 --- a/backend/packages/app/src/windup_app/server/user/model.py +++ b/backend/packages/app/src/windup_app/server/user/model.py @@ -114,8 +114,8 @@ class RegisterInput: email: str password: str code: str + invite_code: str nickname: str | None = None - invite_code: str = "" @dataclass diff --git a/backend/packages/app/src/windup_app/server/user/service.py b/backend/packages/app/src/windup_app/server/user/service.py index f7009d26..a8f8d501 100644 --- a/backend/packages/app/src/windup_app/server/user/service.py +++ b/backend/packages/app/src/windup_app/server/user/service.py @@ -168,15 +168,13 @@ def redis(self) -> redis_lib.Redis: # -- 注册 ------------------------------------------------------------ def register_by_email(self, session: Session, input: RegisterInput) -> LoginResult: - """邮箱+验证码+密码注册。须填写有效邀请码。""" + """邮箱+验证码+密码注册。请求体须带邀请链接中的有效邀请码。""" from windup_app.server.quota.service import ( - normalize_invite_code, + parse_invite_code, service as quota_service, ) - invite_code = normalize_invite_code(input.invite_code) - if not invite_code: - raise BizException("请填写邀请码", code=BizCode.BAD_REQUEST) + invite_code = parse_invite_code(input.invite_code) invite_exists = session.scalar( select(InviteCode.id).where(InviteCode.code == invite_code).limit(1) diff --git a/backend/packages/app/src/windup_app/web/api/auth.py b/backend/packages/app/src/windup_app/web/api/auth.py index 953a6ffc..7cfe5f16 100644 --- a/backend/packages/app/src/windup_app/web/api/auth.py +++ b/backend/packages/app/src/windup_app/web/api/auth.py @@ -37,7 +37,11 @@ class RegisterRequest(BaseModel): password: str = Field(min_length=8, max_length=128) code: str = Field(min_length=6, max_length=6, description="邮箱验证码") nickname: str | None = Field(default=None, max_length=50) - invite_code: str = Field(min_length=4, max_length=16, description="邀请码") + invite_code: str = Field( + min_length=4, + max_length=16, + description="邀请链接中的邀请码,注册时由前端从查询参数传入", + ) class LoginRequest(BaseModel): @@ -120,7 +124,7 @@ class UserOut(BaseModel): @router.post("/register", response_model=Response[TokenResponse]) def register(body: RegisterRequest, session: Session = Depends(get_session)): - """邮箱+验证码+密码注册。须填写有效邀请码。""" + """邮箱+验证码+密码注册。须在请求体携带邀请链接中的有效邀请码。""" result = service.register_by_email( session, RegisterInput( diff --git a/backend/tests/test_auth_registration_closed.py b/backend/tests/test_auth_registration_closed.py index 731e227a..574bc78d 100644 --- a/backend/tests/test_auth_registration_closed.py +++ b/backend/tests/test_auth_registration_closed.py @@ -1,4 +1,4 @@ -"""注册须填写有效邀请码;无邀请码不得建号。""" +"""注册须携带邀请链接中的有效邀请码;无邀请码不得建号。""" from windup_common.enums.biz_code import BizCode diff --git a/backend/tests/test_quota.py b/backend/tests/test_quota.py index e9afca85..91577bc3 100644 --- a/backend/tests/test_quota.py +++ b/backend/tests/test_quota.py @@ -666,6 +666,8 @@ def test_redeem_rejects_blank_or_unknown_code(self, db_session, quota_service): with pytest.raises(BizException, match="邀请码无效"): quota_service.redeem_invite_code(db_session, guest.id, " ") + with pytest.raises(BizException, match="邀请码无效"): + quota_service.redeem_invite_code(db_session, guest.id, "IO01") with pytest.raises(BizException, match="邀请码无效"): quota_service.redeem_invite_code(db_session, guest.id, "NOPE1234") diff --git a/backend/tests/test_user_service.py b/backend/tests/test_user_service.py index 57e89acc..be68803e 100644 --- a/backend/tests/test_user_service.py +++ b/backend/tests/test_user_service.py @@ -282,7 +282,21 @@ def test_register_blank_invite_code(db_session, service): invite_code=" ", ) - with pytest.raises(BizException, match="请填写邀请码"): + with pytest.raises(BizException, match="邀请码无效"): + service.register_by_email(db_session, input_data) + + +def test_register_rejects_invite_code_outside_link_charset(db_session, service): + """前端邀请链接用 A-H/J-N/P-Z/2-9,含 I/O/0/1 的码不会进注册请求。""" + service._redis.get.return_value = "123456" + input_data = RegisterInput( + email="bad-charset@example.com", + password="password123", + code="123456", + invite_code="IIII", + ) + + with pytest.raises(BizException, match="邀请码无效"): service.register_by_email(db_session, input_data) diff --git a/openapi.json b/openapi.json index bf90a498..e44c1409 100644 --- a/openapi.json +++ b/openapi.json @@ -1290,7 +1290,7 @@ "type": "string" }, "invite_code": { - "description": "邀请码", + "description": "邀请链接中的邀请码,注册时由前端从查询参数传入", "maxLength": 16, "minLength": 4, "title": "Invite Code", @@ -2382,7 +2382,7 @@ }, "/auth/register": { "post": { - "description": "邮箱+验证码+密码注册。须填写有效邀请码。", + "description": "邮箱+验证码+密码注册。须在请求体携带邀请链接中的有效邀请码。", "operationId": "register_auth_register_post", "requestBody": { "content": { From 2c7ca426468cd0d0cfbcd0a87c86b97fc356935f Mon Sep 17 00:00:00 2001 From: xiaocheny214 <187097481+xiaocheny214@users.noreply.github.com> Date: Tue, 18 Aug 2026 11:22:48 +0800 Subject: [PATCH 07/10] =?UTF-8?q?feat(auth):=20=E9=82=80=E8=AF=B7=E7=A0=81?= =?UTF-8?q?=E9=80=89=E5=A1=AB=EF=BC=8C=E9=AA=8C=E8=AF=81=E7=A0=81=E7=99=BB?= =?UTF-8?q?=E5=BD=95=E5=8F=AF=E5=BB=BA=E5=8F=B7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 注册无邀请码只发注册赠送;有有效邀请码再发双方奖励。login-by-code 对未知邮箱建号。注册赠送默认改为 300,邀请奖励保持 200。 --- .env.example | 2 +- .../src/windup_app/server/user/interface.py | 6 +-- .../app/src/windup_app/server/user/model.py | 2 +- .../app/src/windup_app/server/user/service.py | 42 ++++++++++++------- .../app/src/windup_app/web/api/auth.py | 19 ++++++--- .../src/windup_framework/config/quota.py | 4 +- backend/tests/test_auth_api.py | 33 +++++++++++++++ .../tests/test_auth_registration_closed.py | 18 +------- backend/tests/test_user_service.py | 37 ++++++++++------ openapi.json | 23 ++++++---- 10 files changed, 119 insertions(+), 67 deletions(-) diff --git a/.env.example b/.env.example index 7797e0b6..980645cd 100644 --- a/.env.example +++ b/.env.example @@ -40,7 +40,7 @@ AI_IMAGE_MODEL=gemini-2.5-flash-image AI_VIDEO_MODEL=kling-v2-5-turbo # ── 积分定价 ── -QUOTA_REGISTER_GIFT_AMOUNT=100 +QUOTA_REGISTER_GIFT_AMOUNT=300 QUOTA_INVITE_REWARD_AMOUNT=200 QUOTA_GENERATE_IMAGE_COST=10 QUOTA_GENERATE_ACTION_COST=50 diff --git a/backend/packages/app/src/windup_app/server/user/interface.py b/backend/packages/app/src/windup_app/server/user/interface.py index bb6ffbc1..6ea3d826 100644 --- a/backend/packages/app/src/windup_app/server/user/interface.py +++ b/backend/packages/app/src/windup_app/server/user/interface.py @@ -27,7 +27,7 @@ class UserService(ABC): @abstractmethod def register_by_email(self, session: Session, input: RegisterInput) -> LoginResult: - """邮箱+验证码+密码注册。请求体须带邀请链接中的有效邀请码。 + """邮箱+验证码+密码注册。邀请码选填。 :raises windup_common.exceptions.BizException: 邮箱已注册 / 邀请码无效。 """ @@ -53,9 +53,9 @@ def send_verification_code(self, email: str, purpose: str) -> None: @abstractmethod def login_by_code(self, session: Session, input: LoginByCodeInput) -> LoginResult: - """邮箱+验证码登录。未知邮箱不自动建号。 + """邮箱+验证码登录。未知邮箱自动建号并赠送注册积分。 - :raises windup_common.exceptions.BizException: 验证码错误 / 已过期 / 账号不存在 / 账号已封禁。 + :raises windup_common.exceptions.BizException: 验证码错误 / 已过期 / 账号已封禁。 """ # -- 登出 ------------------------------------------------------------ diff --git a/backend/packages/app/src/windup_app/server/user/model.py b/backend/packages/app/src/windup_app/server/user/model.py index 552e0139..c7460228 100644 --- a/backend/packages/app/src/windup_app/server/user/model.py +++ b/backend/packages/app/src/windup_app/server/user/model.py @@ -114,8 +114,8 @@ class RegisterInput: email: str password: str code: str - invite_code: str nickname: str | None = None + invite_code: str | None = None @dataclass diff --git a/backend/packages/app/src/windup_app/server/user/service.py b/backend/packages/app/src/windup_app/server/user/service.py index a8f8d501..db843a64 100644 --- a/backend/packages/app/src/windup_app/server/user/service.py +++ b/backend/packages/app/src/windup_app/server/user/service.py @@ -168,19 +168,20 @@ def redis(self) -> redis_lib.Redis: # -- 注册 ------------------------------------------------------------ def register_by_email(self, session: Session, input: RegisterInput) -> LoginResult: - """邮箱+验证码+密码注册。请求体须带邀请链接中的有效邀请码。""" + """邮箱+验证码+密码注册。邀请码选填。""" from windup_app.server.quota.service import ( parse_invite_code, service as quota_service, ) - invite_code = parse_invite_code(input.invite_code) - - invite_exists = session.scalar( - select(InviteCode.id).where(InviteCode.code == invite_code).limit(1) - ) - if invite_exists is None: - raise BizException("邀请码无效", code=BizCode.BAD_REQUEST) + raw_invite = (input.invite_code or "").strip() + invite_code = parse_invite_code(raw_invite) if raw_invite else None + if invite_code is not None: + invite_exists = session.scalar( + select(InviteCode.id).where(InviteCode.code == invite_code).limit(1) + ) + if invite_exists is None: + raise BizException("邀请码无效", code=BizCode.BAD_REQUEST) self._verify_code(input.email, input.code, "register") @@ -202,9 +203,10 @@ def register_by_email(self, session: Session, input: RegisterInput) -> LoginResu session.add(user) session.flush() - # 注册送积分 + # 注册送积分;有邀请码再发双方邀请奖励 self._create_credit_account(session, user.id) - quota_service.redeem_invite_code(session, user.id, invite_code) + if invite_code is not None: + quota_service.redeem_invite_code(session, user.id, invite_code) # 注册即登录,签发 token access_token = create_access_token(user.id, user.email) @@ -316,17 +318,25 @@ def _verify_code(self, email: str, code: str, purpose: str) -> None: self.redis.delete(code_key) def login_by_code(self, session: Session, input: LoginByCodeInput) -> LoginResult: - """邮箱+验证码登录。未知邮箱不自动建号。""" + """邮箱+验证码登录。未知邮箱自动建号并赠送注册积分。""" # 校验验证码 self._verify_code(input.email, input.code, "login") user = session.scalar(select(User).where(User.email == input.email)) if user is None: - raise BizException("账号不存在", code=BizCode.NOT_FOUND) - if user.status == UserStatus.BANNED: - raise BizException("账号已被封禁", code=BizCode.BAD_REQUEST) - if user.email_verified_at is None: - user.email_verified_at = datetime.now(timezone.utc) + user = User( + email=input.email, + password_hash="", + email_verified_at=datetime.now(timezone.utc), + ) + session.add(user) + session.flush() + self._create_credit_account(session, user.id) + else: + if user.status == UserStatus.BANNED: + raise BizException("账号已被封禁", code=BizCode.BAD_REQUEST) + if user.email_verified_at is None: + user.email_verified_at = datetime.now(timezone.utc) user.last_login_at = datetime.now(timezone.utc) session.flush() diff --git a/backend/packages/app/src/windup_app/web/api/auth.py b/backend/packages/app/src/windup_app/web/api/auth.py index 7cfe5f16..eb76d7db 100644 --- a/backend/packages/app/src/windup_app/web/api/auth.py +++ b/backend/packages/app/src/windup_app/web/api/auth.py @@ -6,7 +6,7 @@ import logging from fastapi import APIRouter, Depends, Request -from pydantic import BaseModel, ConfigDict, Field, EmailStr +from pydantic import BaseModel, ConfigDict, Field, EmailStr, field_validator from sqlalchemy.orm import Session from windup_common.result import Response @@ -37,12 +37,19 @@ class RegisterRequest(BaseModel): password: str = Field(min_length=8, max_length=128) code: str = Field(min_length=6, max_length=6, description="邮箱验证码") nickname: str | None = Field(default=None, max_length=50) - invite_code: str = Field( - min_length=4, + invite_code: str | None = Field( + default=None, max_length=16, - description="邀请链接中的邀请码,注册时由前端从查询参数传入", + description="邀请链接中的邀请码,选填;有则发双方邀请奖励", ) + @field_validator("invite_code", mode="before") + @classmethod + def blank_invite_code(cls, value: object) -> object: + if isinstance(value, str) and not value.strip(): + return None + return value + class LoginRequest(BaseModel): """密码登录请求。""" @@ -124,7 +131,7 @@ class UserOut(BaseModel): @router.post("/register", response_model=Response[TokenResponse]) def register(body: RegisterRequest, session: Session = Depends(get_session)): - """邮箱+验证码+密码注册。须在请求体携带邀请链接中的有效邀请码。""" + """邮箱+验证码+密码注册。邀请码选填。""" result = service.register_by_email( session, RegisterInput( @@ -173,7 +180,7 @@ def send_code(body: SendCodeRequest): @router.post("/login-by-code", response_model=Response[TokenResponse]) def login_by_code(body: LoginByCodeRequest, session: Session = Depends(get_session)): - """验证码登录。未知邮箱不自动建号。""" + """验证码登录。未知邮箱自动建号并赠送注册积分。""" result = service.login_by_code( session, type("LoginByCodeInput", (), {"email": body.email, "code": body.code})(), diff --git a/backend/packages/framework/src/windup_framework/config/quota.py b/backend/packages/framework/src/windup_framework/config/quota.py index 57add5fb..c9bf4b57 100644 --- a/backend/packages/framework/src/windup_framework/config/quota.py +++ b/backend/packages/framework/src/windup_framework/config/quota.py @@ -9,7 +9,7 @@ class QuotaSettings(BaseSettings): """积分定价配置。 - 环境变量前缀 ``QUOTA_``,例如 ``QUOTA_REGISTER_GIFT_AMOUNT=100``。 + 环境变量前缀 ``QUOTA_``,例如 ``QUOTA_REGISTER_GIFT_AMOUNT=300``。 """ model_config = SettingsConfigDict( @@ -20,7 +20,7 @@ class QuotaSettings(BaseSettings): ) # -- 注册 / 邀请 ------------------------------------------------------- - register_gift_amount: int = 100 # 注册赠送积分 + register_gift_amount: int = 300 # 注册赠送积分 invite_reward_amount: int = 200 # 邀请奖励(双方各得) # -- 生成任务 ----------------------------------------------------------- diff --git a/backend/tests/test_auth_api.py b/backend/tests/test_auth_api.py index 0641c268..1eb8cff9 100644 --- a/backend/tests/test_auth_api.py +++ b/backend/tests/test_auth_api.py @@ -116,6 +116,39 @@ def test_register_endpoint_success(client, db_session, mock_user_redis): assert body["data"]["access_token"] +def test_register_endpoint_success_without_invite_code(client, mock_user_redis): + mock_user_redis.get.return_value = "123456" + resp = client.post( + "/auth/register", + json={ + "email": "open@example.com", + "password": "password123", + "code": "123456", + }, + ) + assert resp.status_code == 200 + body = resp.json() + assert body["code"] == 200 + assert body["data"]["user"]["email"] == "open@example.com" + assert body["data"]["access_token"] + + +def test_login_by_code_endpoint_creates_unknown_email(client, db_session, mock_user_redis): + mock_user_redis.get.return_value = "123456" + resp = client.post( + "/auth/login-by-code", + json={"email": "fresh@example.com", "code": "123456"}, + ) + assert resp.status_code == 200 + body = resp.json() + assert body["code"] == 200 + assert body["data"]["user"]["email"] == "fresh@example.com" + assert ( + db_session.query(User).filter(User.email == "fresh@example.com").one_or_none() + is not None + ) + + def test_update_nickname_endpoint(auth_client, seeded_user, mock_user_redis): resp = auth_client.patch("/auth/profile", json={"nickname": "新昵称"}) assert resp.status_code == 200 diff --git a/backend/tests/test_auth_registration_closed.py b/backend/tests/test_auth_registration_closed.py index 574bc78d..28fa99df 100644 --- a/backend/tests/test_auth_registration_closed.py +++ b/backend/tests/test_auth_registration_closed.py @@ -1,26 +1,10 @@ -"""注册须携带邀请链接中的有效邀请码;无邀请码不得建号。""" +"""无效邀请码不得建号。""" from windup_common.enums.biz_code import BizCode from windup_app.server.user.model import User -def test_register_endpoint_requires_invite_code(client): - resp = client.post( - "/auth/register", - json={ - "email": "new@example.com", - "password": "password123", - "code": "123456", - }, - ) - assert resp.status_code == 200 - body = resp.json() - assert body["code"] == BizCode.BAD_REQUEST - assert body["data"] is not None - assert any("invite_code" in str(item) for item in body["data"]) - - def test_register_endpoint_rejects_invalid_invite_code(client, db_session): resp = client.post( "/auth/register", diff --git a/backend/tests/test_user_service.py b/backend/tests/test_user_service.py index be68803e..14bfa3a0 100644 --- a/backend/tests/test_user_service.py +++ b/backend/tests/test_user_service.py @@ -273,7 +273,12 @@ def test_register_expired_code(db_session, service): service.register_by_email(db_session, input_data) -def test_register_blank_invite_code(db_session, service): +def test_register_blank_invite_code_only_gives_register_gift(db_session, service): + """未带邀请码时只发注册赠送,不挡注册。""" + from sqlalchemy import select + from windup_app.server.quota.model import CreditAccount + from windup_framework.config.quota import settings as quota_settings + service._redis.get.return_value = "123456" input_data = RegisterInput( email="blank-invite@example.com", @@ -282,8 +287,12 @@ def test_register_blank_invite_code(db_session, service): invite_code=" ", ) - with pytest.raises(BizException, match="邀请码无效"): - service.register_by_email(db_session, input_data) + result = service.register_by_email(db_session, input_data) + account = db_session.scalar( + select(CreditAccount).where(CreditAccount.user_id == result.user.id) + ) + assert account is not None + assert account.balance == quota_settings.register_gift_amount def test_register_rejects_invite_code_outside_link_charset(db_session, service): @@ -381,24 +390,28 @@ def test_login_banned_user(db_session, service, mock_email): # -- 验证码登录测试 ------------------------------------------------------ -def test_login_by_code_unknown_email_does_not_create_user( +def test_login_by_code_unknown_email_creates_user_and_gifts( db_session, service, mock_email ): - """内测关闭公开注册后,验证码登录不得自动建号。""" + """未知邮箱验证码登录自动建号,并只发注册赠送。""" from sqlalchemy import select + from windup_app.server.quota.model import CreditAccount + from windup_framework.config.quota import settings as quota_settings service._redis.get.return_value = "123456" input_data = LoginByCodeInput(email="code@example.com", code="123456") - with pytest.raises(BizException, match="账号不存在") as exc: - service.login_by_code(db_session, input_data) - - from windup_common.enums.biz_code import BizCode + result = service.login_by_code(db_session, input_data) - assert exc.value.code == BizCode.NOT_FOUND - assert ( - db_session.scalar(select(User).where(User.email == "code@example.com")) is None + user = db_session.scalar(select(User).where(User.email == "code@example.com")) + assert user is not None + assert result.user.id == user.id + assert result.user.email_verified_at is not None + account = db_session.scalar( + select(CreditAccount).where(CreditAccount.user_id == user.id) ) + assert account is not None + assert account.balance == quota_settings.register_gift_amount def test_send_verification_code_allows_register_purpose(service, mock_email): diff --git a/openapi.json b/openapi.json index e44c1409..4082b07f 100644 --- a/openapi.json +++ b/openapi.json @@ -1290,11 +1290,17 @@ "type": "string" }, "invite_code": { - "description": "邀请链接中的邀请码,注册时由前端从查询参数传入", - "maxLength": 16, - "minLength": 4, - "title": "Invite Code", - "type": "string" + "anyOf": [ + { + "maxLength": 16, + "type": "string" + }, + { + "type": "null" + } + ], + "description": "邀请链接中的邀请码,选填;有则发双方邀请奖励", + "title": "Invite Code" }, "nickname": { "anyOf": [ @@ -1318,8 +1324,7 @@ "required": [ "email", "password", - "code", - "invite_code" + "code" ], "title": "RegisterRequest", "type": "object" @@ -2192,7 +2197,7 @@ }, "/auth/login-by-code": { "post": { - "description": "验证码登录。未知邮箱不自动建号。", + "description": "验证码登录。未知邮箱自动建号并赠送注册积分。", "operationId": "login_by_code_auth_login_by_code_post", "requestBody": { "content": { @@ -2382,7 +2387,7 @@ }, "/auth/register": { "post": { - "description": "邮箱+验证码+密码注册。须在请求体携带邀请链接中的有效邀请码。", + "description": "邮箱+验证码+密码注册。邀请码选填。", "operationId": "register_auth_register_post", "requestBody": { "content": { From 565f1ba4330985822207e0576be3f1660b2d52dc Mon Sep 17 00:00:00 2001 From: xiaocheny214 <187097481+xiaocheny214@users.noreply.github.com> Date: Tue, 18 Aug 2026 12:01:24 +0800 Subject: [PATCH 08/10] =?UTF-8?q?feat(auth):=20=E9=82=80=E8=AF=B7=E7=A0=81?= =?UTF-8?q?=E4=BB=85=E6=B3=A8=E5=86=8C=E5=8F=AF=E7=94=A8=EF=BC=8C=E6=94=B9?= =?UTF-8?q?=E4=B8=BA=20TTL=20=E4=B8=8E=E5=8F=AA=E5=A2=9E=E4=B8=8D=E5=88=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 去掉登录后补填;邀请码 30 天过期,轮换插入新行并截断旧码有效期。 --- .env.example | 1 + .../src/windup_app/server/quota/interface.py | 13 ++- .../app/src/windup_app/server/quota/model.py | 14 ++- .../src/windup_app/server/quota/service.py | 81 +++++++++++------ .../app/src/windup_app/server/user/service.py | 8 +- .../app/src/windup_app/web/api/quota.py | 29 ++---- .../src/windup_framework/config/quota.py | 1 + backend/tests/test_quota.py | 90 ++++++++++++++++--- backend/tests/test_user_service.py | 23 +++++ openapi.json | 68 ++------------ 10 files changed, 190 insertions(+), 138 deletions(-) diff --git a/.env.example b/.env.example index 980645cd..f88eb98e 100644 --- a/.env.example +++ b/.env.example @@ -42,6 +42,7 @@ AI_VIDEO_MODEL=kling-v2-5-turbo # ── 积分定价 ── QUOTA_REGISTER_GIFT_AMOUNT=300 QUOTA_INVITE_REWARD_AMOUNT=200 +QUOTA_INVITE_CODE_TTL_DAYS=30 QUOTA_GENERATE_IMAGE_COST=10 QUOTA_GENERATE_ACTION_COST=50 diff --git a/backend/packages/app/src/windup_app/server/quota/interface.py b/backend/packages/app/src/windup_app/server/quota/interface.py index 5c3f94ed..c6750256 100644 --- a/backend/packages/app/src/windup_app/server/quota/interface.py +++ b/backend/packages/app/src/windup_app/server/quota/interface.py @@ -10,6 +10,7 @@ from windup_app.server.quota.model import ( CreditAccountView, CreditTransactionView, + InviteCode, InviteCodeView, ) @@ -92,15 +93,19 @@ def list_transactions( @abstractmethod def get_invite_code(self, session: Session, user_id: int) -> InviteCodeView: - """获取用户当前邀请码;没有则生成。""" + """获取当前未过期邀请码;没有或已过期则签发新行。""" @abstractmethod def generate_invite_code(self, session: Session, user_id: int) -> InviteCodeView: - """生成新邀请码(替换旧码)。已有行会 FOR UPDATE,旧码立即失效。""" + """签发新邀请码:插入新行,仍有效的旧码立即过期但保留。""" + + @abstractmethod + def require_active_invite(self, session: Session, code: str) -> InviteCode: + """注册前校验邀请码存在且未过期。非法返回「邀请码无效」,过期返回「邀请码已过期」。""" @abstractmethod def redeem_invite_code(self, session: Session, user_id: int, code: str) -> None: - """兑换邀请码,双方各得积分。 + """注册时兑换邀请码,双方各得积分。不提供登录后补填。 - :raises BizException: 邀请码无效 / 已填过码 / 不能填自己的码。 + :raises BizException: 邀请码无效 / 已过期 / 已填过码 / 不能填自己的码。 """ diff --git a/backend/packages/app/src/windup_app/server/quota/model.py b/backend/packages/app/src/windup_app/server/quota/model.py index 568bfcaf..e04a0ff3 100644 --- a/backend/packages/app/src/windup_app/server/quota/model.py +++ b/backend/packages/app/src/windup_app/server/quota/model.py @@ -15,7 +15,7 @@ """ from dataclasses import dataclass, field -from datetime import datetime, timezone +from datetime import datetime, timedelta, timezone from sqlalchemy import ( BigInteger, @@ -27,6 +27,7 @@ ) from sqlalchemy.orm import Mapped, mapped_column +from windup_framework.config.quota import settings as quota_settings from windup_framework.db import Base @@ -115,7 +116,7 @@ class CreditTransaction(Base): class InviteCode(Base): - """用户当前邀请码。每人一行,轮换时覆盖 code。""" + """用户邀请码。只增不删;轮换插入新行,旧行保留。""" __tablename__ = "windup_invite_code" @@ -126,11 +127,17 @@ class InviteCode(Base): ) user_id: Mapped[int] = mapped_column( BigInteger().with_variant(Integer, "sqlite"), - unique=True, + index=True, nullable=False, ) code: Mapped[str] = mapped_column(String(16), unique=True, nullable=False) used_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0) + expires_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), + nullable=False, + default=lambda: datetime.now(timezone.utc) + + timedelta(days=quota_settings.invite_code_ttl_days), + ) create_at: Mapped[datetime] = mapped_column( DateTime(timezone=True), nullable=False, @@ -215,5 +222,6 @@ class InviteCodeView: code: str used_count: int = 0 + expires_at: datetime = field(default_factory=lambda: datetime.now(timezone.utc)) create_at: datetime = field(default_factory=lambda: datetime.now(timezone.utc)) update_at: datetime = field(default_factory=lambda: datetime.now(timezone.utc)) diff --git a/backend/packages/app/src/windup_app/server/quota/service.py b/backend/packages/app/src/windup_app/server/quota/service.py index 6d056e51..6ca19cce 100644 --- a/backend/packages/app/src/windup_app/server/quota/service.py +++ b/backend/packages/app/src/windup_app/server/quota/service.py @@ -14,6 +14,7 @@ import logging import re import secrets +from datetime import datetime, timedelta, timezone from sqlalchemy import func, select from sqlalchemy.exc import IntegrityError @@ -48,7 +49,7 @@ def normalize_invite_code(code: str) -> str: def parse_invite_code(code: str) -> str: - """解析邀请链接/补填传入的邀请码,字符集与前端 INVITE_CODE_PATTERN 一致。""" + """解析邀请链接传入的邀请码,字符集与前端 INVITE_CODE_PATTERN 一致。""" normalized = normalize_invite_code(code) if _INVITE_CODE_RE.fullmatch(normalized) is None: raise BizException("邀请码无效", code=BizCode.BAD_REQUEST) @@ -64,10 +65,20 @@ def _is_invitee_unique_violation(exc: IntegrityError) -> bool: return "invitee" in text or "windup_invite_record" in text +def _now() -> datetime: + return datetime.now(timezone.utc) + + +def _is_expired(expires_at: datetime) -> bool: + exp = expires_at if expires_at.tzinfo else expires_at.replace(tzinfo=timezone.utc) + return exp <= _now() + + def _to_invite_view(row: InviteCode) -> InviteCodeView: return InviteCodeView( code=row.code, used_count=row.used_count, + expires_at=row.expires_at, create_at=row.create_at, update_at=row.update_at, ) @@ -347,7 +358,11 @@ def list_transactions( # -- 邀请码 ----------------------------------------------------------- def get_invite_code(self, session: Session, user_id: int) -> InviteCodeView: - row = session.scalar(select(InviteCode).where(InviteCode.user_id == user_id)) + row = session.scalar( + select(InviteCode) + .where(InviteCode.user_id == user_id, InviteCode.expires_at > _now()) + .order_by(InviteCode.id.desc()) + ) if row is not None: return _to_invite_view(row) return self.generate_invite_code(session, user_id) @@ -356,55 +371,63 @@ def generate_invite_code(self, session: Session, user_id: int) -> InviteCodeView if session.get(User, user_id) is None: raise BizException("用户不存在", code=BizCode.NOT_FOUND) - row = session.scalar( - select(InviteCode).where(InviteCode.user_id == user_id).with_for_update() + now = _now() + existing = session.scalars( + select(InviteCode) + .where(InviteCode.user_id == user_id) + .with_for_update() + ).all() + for row in existing: + if not _is_expired(row.expires_at): + row.expires_at = now + + row = InviteCode( + user_id=user_id, + code=self._allocate_invite_code(session), + used_count=0, + expires_at=now + + timedelta(days=quota_settings.invite_code_ttl_days), ) - if row is None: - row = InviteCode( - user_id=user_id, code=self._allocate_invite_code(session), used_count=0 - ) - session.add(row) - else: - row.code = self._allocate_invite_code(session, exclude_id=row.id) + session.add(row) session.flush() logger.info("[WINDUP] 生成邀请码 | user_id=%s code=%s", user_id, row.code) return _to_invite_view(row) - def _allocate_invite_code( - self, session: Session, exclude_id: int | None = None - ) -> str: + def _allocate_invite_code(self, session: Session) -> str: for _ in range(16): code = _new_invite_code() - query = select(InviteCode.id).where(InviteCode.code == code) - if exclude_id is not None: - query = query.where(InviteCode.id != exclude_id) - if session.scalar(query) is None: + if session.scalar(select(InviteCode.id).where(InviteCode.code == code)) is None: return code raise BizException("邀请码生成失败,请稍后重试", code=BizCode.BAD_REQUEST) - def redeem_invite_code(self, session: Session, user_id: int, code: str) -> None: + def require_active_invite(self, session: Session, code: str) -> InviteCode: normalized = parse_invite_code(code) - - existing = session.scalar( - select(InviteRecord.id).where(InviteRecord.invitee_id == user_id) - ) - if existing is not None: - raise BizException("已填写过邀请码", code=BizCode.BAD_REQUEST) - invite = session.scalar( - select(InviteCode).where(InviteCode.code == normalized).with_for_update() + select(InviteCode).where(InviteCode.code == normalized) ) if invite is None: raise BizException("邀请码无效", code=BizCode.BAD_REQUEST) + if _is_expired(invite.expires_at): + raise BizException("邀请码已过期", code=BizCode.NOT_FOUND) + return invite + + def redeem_invite_code(self, session: Session, user_id: int, code: str) -> None: + invite = self.require_active_invite(session, code) if invite.user_id == user_id: raise BizException("不能填写自己的邀请码", code=BizCode.BAD_REQUEST) if session.get(User, user_id) is None: raise BizException("用户不存在", code=BizCode.NOT_FOUND) + existing = session.scalar( + select(InviteRecord.id).where(InviteRecord.invitee_id == user_id) + ) + if existing is not None: + raise BizException("已填写过邀请码", code=BizCode.BAD_REQUEST) + record = InviteRecord( inviter_id=invite.user_id, invitee_id=user_id, - code=normalized, + code=invite.code, ) session.add(record) invite.used_count += 1 @@ -434,7 +457,7 @@ def redeem_invite_code(self, session: Session, user_id: int, code: str) -> None: "[WINDUP] 兑换邀请码 | invitee=%s inviter=%s code=%s", user_id, invite.user_id, - normalized, + invite.code, ) diff --git a/backend/packages/app/src/windup_app/server/user/service.py b/backend/packages/app/src/windup_app/server/user/service.py index db843a64..572cf099 100644 --- a/backend/packages/app/src/windup_app/server/user/service.py +++ b/backend/packages/app/src/windup_app/server/user/service.py @@ -25,7 +25,7 @@ from windup_common.exceptions import BizException from windup_framework.config.quota import settings as quota_settings -from windup_app.server.quota.model import CreditAccount, CreditTransaction, InviteCode +from windup_app.server.quota.model import CreditAccount, CreditTransaction from windup_app.server.user.interface import UserService from windup_app.server.user.model import ( ChangePasswordInput, @@ -177,11 +177,7 @@ def register_by_email(self, session: Session, input: RegisterInput) -> LoginResu raw_invite = (input.invite_code or "").strip() invite_code = parse_invite_code(raw_invite) if raw_invite else None if invite_code is not None: - invite_exists = session.scalar( - select(InviteCode.id).where(InviteCode.code == invite_code).limit(1) - ) - if invite_exists is None: - raise BizException("邀请码无效", code=BizCode.BAD_REQUEST) + quota_service.require_active_invite(session, invite_code) self._verify_code(input.email, input.code, "register") diff --git a/backend/packages/app/src/windup_app/web/api/quota.py b/backend/packages/app/src/windup_app/web/api/quota.py index c615d897..85e6c257 100644 --- a/backend/packages/app/src/windup_app/web/api/quota.py +++ b/backend/packages/app/src/windup_app/web/api/quota.py @@ -5,8 +5,7 @@ GET /quota/balance 查询积分余额 GET /quota/transactions 查询积分流水(分页) GET /quota/invite/code 获取我的邀请码 -POST /quota/invite/generate 生成新邀请码 -POST /quota/invite/redeem 兑换邀请码 +POST /quota/invite/generate 签发新邀请码 """ from __future__ import annotations @@ -15,7 +14,7 @@ from datetime import datetime from fastapi import APIRouter, Depends, Query, Request -from pydantic import BaseModel, ConfigDict, Field +from pydantic import BaseModel, ConfigDict from sqlalchemy.orm import Session from windup_common.result import ListResponse, Response @@ -66,16 +65,11 @@ class InviteCodeOut(BaseModel): code: str used_count: int + expires_at: datetime create_at: datetime update_at: datetime -class RedeemRequest(BaseModel): - """兑换邀请码请求。""" - - code: str = Field(min_length=4, max_length=16) - - # -- 端点 ---------------------------------------------------------------- @@ -120,12 +114,13 @@ def get_invite_code( request: Request, session: Session = Depends(get_session), ) -> Response[InviteCodeOut]: - """获取当前用户邀请码;没有则生成。""" + """获取当前用户未过期邀请码;没有或已过期则签发新行。""" view = service.get_invite_code(session, request.state.current_user.id) return Response.success( InviteCodeOut( code=view.code, used_count=view.used_count, + expires_at=view.expires_at, create_at=view.create_at, update_at=view.update_at, ) @@ -137,25 +132,15 @@ def generate_invite_code( request: Request, session: Session = Depends(get_session), ) -> Response[InviteCodeOut]: - """生成或轮换当前用户邀请码。轮换后旧码立即失效。""" + """签发新邀请码。旧码立即过期,行保留。""" view = service.generate_invite_code(session, request.state.current_user.id) return Response.success( InviteCodeOut( code=view.code, used_count=view.used_count, + expires_at=view.expires_at, create_at=view.create_at, update_at=view.update_at, ), message="邀请码已更新", ) - - -@router.post("/invite/redeem", response_model=Response[None]) -def redeem_invite_code( - body: RedeemRequest, - request: Request, - session: Session = Depends(get_session), -) -> Response[None]: - """已登录用户补填邀请码,双方发放邀请奖励。每人限一次。""" - service.redeem_invite_code(session, request.state.current_user.id, body.code) - return Response.success(None, message="邀请码填写成功") diff --git a/backend/packages/framework/src/windup_framework/config/quota.py b/backend/packages/framework/src/windup_framework/config/quota.py index c9bf4b57..734921ae 100644 --- a/backend/packages/framework/src/windup_framework/config/quota.py +++ b/backend/packages/framework/src/windup_framework/config/quota.py @@ -22,6 +22,7 @@ class QuotaSettings(BaseSettings): # -- 注册 / 邀请 ------------------------------------------------------- register_gift_amount: int = 300 # 注册赠送积分 invite_reward_amount: int = 200 # 邀请奖励(双方各得) + invite_code_ttl_days: int = 30 # 邀请码有效期(天) # -- 生成任务 ----------------------------------------------------------- generate_image_cost: int = 10 # 生成角色参考图 diff --git a/backend/tests/test_quota.py b/backend/tests/test_quota.py index 91577bc3..fbf35f26 100644 --- a/backend/tests/test_quota.py +++ b/backend/tests/test_quota.py @@ -524,12 +524,14 @@ def test_get_invite_code_creates_when_missing(self, auth_quota_client): again = auth_quota_client.get("/quota/invite/code") assert again.json()["data"]["code"] == data["data"]["code"] + assert again.json()["data"]["expires_at"] def test_generate_invite_code_rotates(self, auth_quota_client): first = auth_quota_client.get("/quota/invite/code").json()["data"]["code"] second = auth_quota_client.post("/quota/invite/generate").json()["data"]["code"] assert second != first assert len(second) == 8 + assert auth_quota_client.get("/quota/invite/code").json()["data"]["code"] == second def test_generate_invite_code_locks_existing_row( self, db_session, quota_service, monkeypatch @@ -553,6 +555,55 @@ def tracking(self, *args, **kwargs): quota_service.generate_invite_code(db_session, host.id) assert locked, "轮换已有邀请码时应对该行加 FOR UPDATE" + def test_generate_invite_code_keeps_old_row(self, db_session, quota_service): + from datetime import datetime, timezone + from sqlalchemy import select + from windup_app.server.quota.model import InviteCode + from windup_app.server.user.model import User + + host = User(email="append-host@example.com", password_hash="x") + db_session.add(host) + db_session.flush() + first = quota_service.generate_invite_code(db_session, host.id) + second = quota_service.generate_invite_code(db_session, host.id) + rows = db_session.scalars( + select(InviteCode).where(InviteCode.user_id == host.id) + ).all() + assert {row.code for row in rows} == {first.code, second.code} + old = next(row for row in rows if row.code == first.code) + now = datetime.now(timezone.utc) + exp = old.expires_at if old.expires_at.tzinfo else old.expires_at.replace( + tzinfo=timezone.utc + ) + assert exp <= now + + def test_get_invite_code_issues_new_row_after_expiry( + self, db_session, quota_service + ): + from datetime import datetime, timedelta, timezone + from sqlalchemy import select + from windup_app.server.quota.model import InviteCode + from windup_app.server.user.model import User + + host = User(email="expire-host@example.com", password_hash="x") + db_session.add(host) + db_session.flush() + first = quota_service.generate_invite_code(db_session, host.id) + row = db_session.scalar( + select(InviteCode).where(InviteCode.code == first.code) + ) + row.expires_at = datetime.now(timezone.utc) - timedelta(seconds=1) + db_session.flush() + + second = quota_service.get_invite_code(db_session, host.id) + assert second.code != first.code + assert ( + db_session.scalar( + select(InviteCode).where(InviteCode.code == first.code) + ) + is not None + ) + def test_redeem_unique_violation_is_already_redeemed( self, db_session, quota_service, monkeypatch ): @@ -671,31 +722,42 @@ def test_redeem_rejects_blank_or_unknown_code(self, db_session, quota_service): with pytest.raises(BizException, match="邀请码无效"): quota_service.redeem_invite_code(db_session, guest.id, "NOPE1234") - def test_redeem_rejects_missing_invitee(self, db_session, quota_service): + def test_redeem_rejects_expired_code(self, db_session, quota_service): + from datetime import datetime, timedelta, timezone + from sqlalchemy import select + from windup_app.server.quota.model import InviteCode from windup_app.server.user.model import User + from windup_common.enums.biz_code import BizCode from windup_common.exceptions import BizException - host = User(email="orphan-host@example.com", password_hash="x") - db_session.add(host) + host = User(email="stale-host@example.com", password_hash="x") + guest = User(email="stale-guest@example.com", password_hash="x") + db_session.add_all([host, guest]) db_session.flush() _gift_account(db_session, host.id) + _gift_account(db_session, guest.id) view = quota_service.generate_invite_code(db_session, host.id) + row = db_session.scalar(select(InviteCode).where(InviteCode.code == view.code)) + row.expires_at = datetime.now(timezone.utc) - timedelta(seconds=1) + db_session.flush() - with pytest.raises(BizException, match="用户不存在"): - quota_service.redeem_invite_code(db_session, 999999, view.code) + with pytest.raises(BizException, match="邀请码已过期") as exc: + quota_service.redeem_invite_code(db_session, guest.id, view.code) + assert exc.value.code == BizCode.NOT_FOUND - def test_redeem_invite_code_endpoint(self, auth_quota_client, db_session): + def test_redeem_rejects_missing_invitee(self, db_session, quota_service): from windup_app.server.user.model import User + from windup_common.exceptions import BizException - host = User(email="api-host@example.com", password_hash="x") + host = User(email="orphan-host@example.com", password_hash="x") db_session.add(host) db_session.flush() _gift_account(db_session, host.id) - view = SqlAlchemyQuotaService().generate_invite_code(db_session, host.id) - db_session.commit() + view = quota_service.generate_invite_code(db_session, host.id) - resp = auth_quota_client.post("/quota/invite/redeem", json={"code": view.code}) - assert resp.status_code == 200 - body = resp.json() - assert body["code"] == 200 - assert body["message"] == "邀请码填写成功" + with pytest.raises(BizException, match="用户不存在"): + quota_service.redeem_invite_code(db_session, 999999, view.code) + + def test_invite_redeem_endpoint_removed(self, auth_quota_client): + resp = auth_quota_client.post("/quota/invite/redeem", json={"code": "AB23CD45"}) + assert resp.status_code == 404 diff --git a/backend/tests/test_user_service.py b/backend/tests/test_user_service.py index 14bfa3a0..31cc1761 100644 --- a/backend/tests/test_user_service.py +++ b/backend/tests/test_user_service.py @@ -309,6 +309,29 @@ def test_register_rejects_invite_code_outside_link_charset(db_session, service): service.register_by_email(db_session, input_data) +def test_register_expired_invite_code(db_session, service): + from datetime import datetime, timedelta, timezone + from sqlalchemy import select + from windup_app.server.quota.model import InviteCode + from windup_common.enums.biz_code import BizCode + + row = db_session.scalar(select(InviteCode).where(InviteCode.code == "AB23CD45")) + row.expires_at = datetime.now(timezone.utc) - timedelta(days=1) + db_session.flush() + + service._redis.get.return_value = "123456" + input_data = RegisterInput( + email="late@example.com", + password="password123", + code="123456", + invite_code="AB23CD45", + ) + with pytest.raises(BizException, match="邀请码已过期") as exc: + service.register_by_email(db_session, input_data) + assert exc.value.code == BizCode.NOT_FOUND + assert db_session.scalar(select(User).where(User.email == "late@example.com")) is None + + # -- 登录测试 ------------------------------------------------------------ diff --git a/openapi.json b/openapi.json index 4082b07f..76594b26 100644 --- a/openapi.json +++ b/openapi.json @@ -740,11 +740,17 @@ "used_count": { "title": "Used Count", "type": "integer" + }, + "expires_at": { + "format": "date-time", + "title": "Expires At", + "type": "string" } }, "required": [ "code", "used_count", + "expires_at", "create_at", "update_at" ], @@ -1244,22 +1250,6 @@ "title": "ProjectOut", "type": "object" }, - "RedeemRequest": { - "description": "兑换邀请码请求。", - "properties": { - "code": { - "maxLength": 16, - "minLength": 4, - "title": "Code", - "type": "string" - } - }, - "required": [ - "code" - ], - "title": "RedeemRequest", - "type": "object" - }, "RefreshRequest": { "description": "刷新 token 请求。", "properties": { @@ -3210,7 +3200,7 @@ }, "/quota/invite/code": { "get": { - "description": "获取当前用户邀请码;没有则生成。", + "description": "获取当前用户未过期邀请码;没有或已过期则签发新行。", "operationId": "get_invite_code_quota_invite_code_get", "responses": { "200": { @@ -3232,7 +3222,7 @@ }, "/quota/invite/generate": { "post": { - "description": "生成或轮换当前用户邀请码。轮换后旧码立即失效。", + "description": "签发新邀请码。旧码立即过期,行保留。", "operationId": "generate_invite_code_quota_invite_generate_post", "responses": { "200": { @@ -3252,48 +3242,6 @@ ] } }, - "/quota/invite/redeem": { - "post": { - "description": "已登录用户补填邀请码,双方发放邀请奖励。每人限一次。", - "operationId": "redeem_invite_code_quota_invite_redeem_post", - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/RedeemRequest" - } - } - }, - "required": true - }, - "responses": { - "200": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Response_NoneType_" - } - } - }, - "description": "Successful Response" - }, - "422": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HTTPValidationError" - } - } - }, - "description": "Validation Error" - } - }, - "summary": "Redeem Invite Code", - "tags": [ - "quota" - ] - } - }, "/quota/transactions": { "get": { "description": "查询积分流水(分页)。", From d5e2f8a7fe131677788eef3dfd43cdfb312477a0 Mon Sep 17 00:00:00 2001 From: xiaocheny214 <187097481+xiaocheny214@users.noreply.github.com> Date: Tue, 18 Aug 2026 12:33:58 +0800 Subject: [PATCH 09/10] =?UTF-8?q?feat(quota):=20=E9=82=80=E8=AF=B7?= =?UTF-8?q?=E4=BA=BA=E6=AF=8F=E6=97=A5=E6=9C=80=E5=A4=9A=203=20=E6=AC=A1?= =?UTF-8?q?=E9=82=80=E8=AF=B7=E5=A5=96=E5=8A=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 未过期邀请码仍可继续被使用并写入关系;邀请人每个 UTC 日最多入账 600 分,超出只跳过邀请人奖励。 --- .env.example | 1 + .../src/windup_app/server/quota/interface.py | 2 +- .../src/windup_app/server/quota/service.py | 41 ++++++++-- .../src/windup_framework/config/quota.py | 1 + backend/tests/test_quota.py | 78 +++++++++++++++++++ 5 files changed, 115 insertions(+), 8 deletions(-) diff --git a/.env.example b/.env.example index f88eb98e..affa5e38 100644 --- a/.env.example +++ b/.env.example @@ -42,6 +42,7 @@ AI_VIDEO_MODEL=kling-v2-5-turbo # ── 积分定价 ── QUOTA_REGISTER_GIFT_AMOUNT=300 QUOTA_INVITE_REWARD_AMOUNT=200 +QUOTA_INVITE_REWARD_DAILY_LIMIT=3 QUOTA_INVITE_CODE_TTL_DAYS=30 QUOTA_GENERATE_IMAGE_COST=10 QUOTA_GENERATE_ACTION_COST=50 diff --git a/backend/packages/app/src/windup_app/server/quota/interface.py b/backend/packages/app/src/windup_app/server/quota/interface.py index c6750256..25b88e66 100644 --- a/backend/packages/app/src/windup_app/server/quota/interface.py +++ b/backend/packages/app/src/windup_app/server/quota/interface.py @@ -105,7 +105,7 @@ def require_active_invite(self, session: Session, code: str) -> InviteCode: @abstractmethod def redeem_invite_code(self, session: Session, user_id: int, code: str) -> None: - """注册时兑换邀请码,双方各得积分。不提供登录后补填。 + """注册时兑换邀请码。被邀请人始终得邀请奖励;邀请人受每日人数上限。 :raises BizException: 邀请码无效 / 已过期 / 已填过码 / 不能填自己的码。 """ diff --git a/backend/packages/app/src/windup_app/server/quota/service.py b/backend/packages/app/src/windup_app/server/quota/service.py index 6ca19cce..e7a8d72f 100644 --- a/backend/packages/app/src/windup_app/server/quota/service.py +++ b/backend/packages/app/src/windup_app/server/quota/service.py @@ -69,6 +69,15 @@ def _now() -> datetime: return datetime.now(timezone.utc) +def _utc_day_start(now: datetime | None = None) -> datetime: + current = now or _now() + if current.tzinfo is None: + current = current.replace(tzinfo=timezone.utc) + return current.astimezone(timezone.utc).replace( + hour=0, minute=0, second=0, microsecond=0 + ) + + def _is_expired(expires_at: datetime) -> bool: exp = expires_at if expires_at.tzinfo else expires_at.replace(tzinfo=timezone.utc) return exp <= _now() @@ -424,6 +433,8 @@ def redeem_invite_code(self, session: Session, user_id: int, code: str) -> None: if existing is not None: raise BizException("已填写过邀请码", code=BizCode.BAD_REQUEST) + self._get_account_for_update(session, invite.user_id) + record = InviteRecord( inviter_id=invite.user_id, invitee_id=user_id, @@ -439,13 +450,29 @@ def redeem_invite_code(self, session: Session, user_id: int, code: str) -> None: raise reward = quota_settings.invite_reward_amount - self.credit( - session, - invite.user_id, - reward, - int(CreditReason.INVITE_REWARD), - f"invite:{user_id}:inviter", - ) + today_count = session.scalar( + select(func.count()) + .select_from(InviteRecord) + .where( + InviteRecord.inviter_id == invite.user_id, + InviteRecord.create_at >= _utc_day_start(), + ) + ) or 0 + if today_count <= quota_settings.invite_reward_daily_limit: + self.credit( + session, + invite.user_id, + reward, + int(CreditReason.INVITE_REWARD), + f"invite:{user_id}:inviter", + ) + else: + logger.info( + "[WINDUP] 邀请人日限额已满,跳过邀请人奖励 | inviter=%s invitee=%s count=%s", + invite.user_id, + user_id, + today_count, + ) self.credit( session, user_id, diff --git a/backend/packages/framework/src/windup_framework/config/quota.py b/backend/packages/framework/src/windup_framework/config/quota.py index 734921ae..af1ca72c 100644 --- a/backend/packages/framework/src/windup_framework/config/quota.py +++ b/backend/packages/framework/src/windup_framework/config/quota.py @@ -22,6 +22,7 @@ class QuotaSettings(BaseSettings): # -- 注册 / 邀请 ------------------------------------------------------- register_gift_amount: int = 300 # 注册赠送积分 invite_reward_amount: int = 200 # 邀请奖励(双方各得) + invite_reward_daily_limit: int = 3 # 邀请人每日可获奖励的邀请人数(3×200=600) invite_code_ttl_days: int = 30 # 邀请码有效期(天) # -- 生成任务 ----------------------------------------------------------- diff --git a/backend/tests/test_quota.py b/backend/tests/test_quota.py index fbf35f26..775c8176 100644 --- a/backend/tests/test_quota.py +++ b/backend/tests/test_quota.py @@ -664,6 +664,84 @@ def test_redeem_invite_code_rewards_both_users(self, db_session, quota_service): == quota_settings.register_gift_amount + quota_settings.invite_reward_amount ) + def test_inviter_daily_reward_stops_after_three_invites( + self, db_session, quota_service + ): + from windup_app.server.quota.model import InviteRecord + from windup_app.server.user.model import User + + inviter = User(email="cap-host@example.com", password_hash="x") + db_session.add(inviter) + db_session.flush() + _gift_account(db_session, inviter.id) + view = quota_service.generate_invite_code(db_session, inviter.id) + + guests = [] + for i in range(4): + guest = User(email=f"cap-guest-{i}@example.com", password_hash="x") + db_session.add(guest) + db_session.flush() + _gift_account(db_session, guest.id) + quota_service.redeem_invite_code(db_session, guest.id, view.code) + guests.append(guest) + + host = quota_service.get_account(db_session, inviter.id) + assert host.balance == quota_settings.register_gift_amount + ( + quota_settings.invite_reward_amount * 3 + ) + assert ( + db_session.scalar( + select(InviteRecord.id).where( + InviteRecord.invitee_id == guests[3].id + ) + ) + is not None + ) + fourth = quota_service.get_account(db_session, guests[3].id) + assert ( + fourth.balance + == quota_settings.register_gift_amount + quota_settings.invite_reward_amount + ) + + def test_inviter_daily_reward_resets_next_utc_day( + self, db_session, quota_service + ): + from datetime import timedelta + from windup_app.server.quota.model import InviteRecord + from windup_app.server.quota.service import _now + from windup_app.server.user.model import User + + inviter = User(email="nextday-host@example.com", password_hash="x") + db_session.add(inviter) + db_session.flush() + _gift_account(db_session, inviter.id) + view = quota_service.generate_invite_code(db_session, inviter.id) + + for i in range(3): + guest = User(email=f"old-guest-{i}@example.com", password_hash="x") + db_session.add(guest) + db_session.flush() + _gift_account(db_session, guest.id) + quota_service.redeem_invite_code(db_session, guest.id, view.code) + + yesterday = _now() - timedelta(days=1) + for row in db_session.scalars( + select(InviteRecord).where(InviteRecord.inviter_id == inviter.id) + ).all(): + row.create_at = yesterday + db_session.flush() + + today_guest = User(email="today-guest@example.com", password_hash="x") + db_session.add(today_guest) + db_session.flush() + _gift_account(db_session, today_guest.id) + quota_service.redeem_invite_code(db_session, today_guest.id, view.code) + + host = quota_service.get_account(db_session, inviter.id) + assert host.balance == quota_settings.register_gift_amount + ( + quota_settings.invite_reward_amount * 4 + ) + def test_redeem_rejects_own_code_and_repeat(self, db_session, quota_service): from windup_app.server.user.model import User from windup_common.exceptions import BizException From ff6face46e0a5f8a4b2b9f200661999a98c15f0f Mon Sep 17 00:00:00 2001 From: xiaocheny214 <187097481+xiaocheny214@users.noreply.github.com> Date: Tue, 18 Aug 2026 12:45:20 +0800 Subject: [PATCH 10/10] =?UTF-8?q?chore(openapi):=20=E6=8C=89=E5=AF=BC?= =?UTF-8?q?=E5=87=BA=E8=84=9A=E6=9C=AC=E6=8E=92=E5=BA=8F=20InviteCodeOut?= =?UTF-8?q?=20=E5=AD=97=E6=AE=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- openapi.json | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/openapi.json b/openapi.json index 76594b26..33cc2cde 100644 --- a/openapi.json +++ b/openapi.json @@ -732,6 +732,11 @@ "title": "Create At", "type": "string" }, + "expires_at": { + "format": "date-time", + "title": "Expires At", + "type": "string" + }, "update_at": { "format": "date-time", "title": "Update At", @@ -740,11 +745,6 @@ "used_count": { "title": "Used Count", "type": "integer" - }, - "expires_at": { - "format": "date-time", - "title": "Expires At", - "type": "string" } }, "required": [