Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions backend/packages/app/src/windup_app/server/user/interface.py
Original file line number Diff line number Diff line change
Expand Up @@ -46,9 +46,9 @@ def send_verification_code(self, email: str, purpose: str) -> None:

@abstractmethod
def login_by_code(self, input: LoginByCodeInput) -> LoginResult:
"""邮箱+验证码登录,无账号时自动注册
"""邮箱+验证码登录。内测期间不自动建号

:raises windup_common.exceptions.BizException: 验证码错误 / 已过期 / 账号已封禁。
:raises windup_common.exceptions.BizException: 验证码错误 / 已过期 / 账号不存在 / 账号已封禁。
"""

# -- 登出 ------------------------------------------------------------
Expand Down
23 changes: 9 additions & 14 deletions backend/packages/app/src/windup_app/server/user/service.py
Original file line number Diff line number Diff line change
Expand Up @@ -280,6 +280,9 @@ 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):
Expand Down Expand Up @@ -315,25 +318,17 @@ 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")

# 查找或创建用户
user = session.scalar(select(User).where(User.email == input.email))
if user is None:
user = User(email=input.email, email_verified_at=datetime.now(timezone.utc))
session.add(user)
session.flush()
# 自动注册送积分
self._create_credit_account(session, user.id)
logger.info("[WINDUP] 验证码自动注册 | user_id=%s email=%s", user.id, user.email)
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)
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.last_login_at = datetime.now(timezone.utc)
session.flush()
Expand Down
24 changes: 10 additions & 14 deletions backend/packages/app/src/windup_app/web/api/auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -111,19 +111,15 @@ class UserOut(BaseModel):

@router.post("/register", response_model=Response[TokenResponse])
def register(body: RegisterRequest, session: Session = Depends(get_session)):
"""邮箱+验证码+密码注册,注册即登录。"""
result = service.register_by_email_with_session(
session,
type("RegisterInput", (), {"email": body.email, "password": body.password, "code": body.code, "nickname": body.nickname})(),
)
return Response.success(
TokenResponse(
access_token=result.access_token,
refresh_token=result.refresh_token,
user=result.user,
),
message="注册成功",
)
"""邮箱+验证码+密码注册。

内测期间关闭公开注册,路由与请求模型保留以便以后重新开放。
"""
from windup_common.enums.biz_code import BizCode
from windup_common.exceptions import BizException

del body, session
raise BizException("内测期间暂不开放注册", code=BizCode.BAD_REQUEST)


@router.post("/login", response_model=Response[TokenResponse])
Expand Down Expand Up @@ -152,7 +148,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})(),
Expand Down
30 changes: 30 additions & 0 deletions backend/tests/test_auth_registration_closed.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
"""内测关闭公开注册:公开建号路径必须被拒绝。"""

from windup_common.enums.biz_code import BizCode


def test_register_endpoint_rejects_public_signup(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["message"] == "内测期间暂不开放注册"
assert body["data"] is None


def test_send_code_rejects_register_purpose(client):
resp = client.post(
"/auth/send-code",
json={"email": "new@example.com", "purpose": "register"},
)
assert resp.status_code == 200
body = resp.json()
assert body["code"] == BizCode.BAD_REQUEST
assert body["message"] == "内测期间暂不开放注册"
74 changes: 46 additions & 28 deletions backend/tests/test_user_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -282,46 +282,64 @@ def test_login_banned_user(db_session, service, mock_email):
# -- 验证码登录测试 ------------------------------------------------------


def test_login_by_code_new_user(db_session, service, mock_email):
service._redis.get.return_value = "123456"
def test_login_by_code_unknown_email_does_not_create_user(db_session, service, mock_email):
"""内测关闭公开注册后,验证码登录不得自动建号。"""
from sqlalchemy import select

service._redis.get.return_value = "123456"
input_data = LoginByCodeInput(email="code@example.com", code="123456")
result = service.login_by_code_with_session(db_session, input_data)

assert result.user.email == "code@example.com"
assert result.user.email_verified_at is not None
with pytest.raises(BizException, match="账号不存在") as exc:
service.login_by_code_with_session(db_session, input_data)

from windup_common.enums.biz_code import BizCode

def test_login_by_code_new_user_creates_credit_account(db_session, service, mock_email):
"""验证码自动注册时应创建积分账户并赠送初始积分。"""
from sqlalchemy import select
from windup_app.server.quota.model import CreditAccount, CreditTransaction
from windup_common.enums.quota import CreditReason
from windup_framework.config.quota import settings as quota_settings
assert exc.value.code == BizCode.NOT_FOUND
assert db_session.scalar(select(User).where(User.email == "code@example.com")) is None

service._redis.get.return_value = "123456"

input_data = LoginByCodeInput(email="auto@example.com", code="123456")
result = service.login_by_code_with_session(db_session, input_data)
user_id = result.user.id
def test_send_verification_code_rejects_register_purpose(service, mock_email):
service._redis.get.return_value = None

# 验证积分账户已创建
account = db_session.scalar(
select(CreditAccount).where(CreditAccount.user_id == user_id)
with pytest.raises(BizException, match="内测期间暂不开放注册"):
service.send_verification_code("new@example.com", "register")

mock_email.send_verification_code.assert_not_called()


def test_login_by_code_banned_user(db_session, service, mock_email):
from sqlalchemy import select

service._redis.get.return_value = "123456"
service.register_by_email_with_session(
db_session,
RegisterInput(email="banned-code@example.com", password="pass123", code="123456"),
)
assert account is not None
assert account.balance == quota_settings.register_gift_amount
assert account.total_earned == quota_settings.register_gift_amount
user = db_session.scalar(select(User).where(User.email == "banned-code@example.com"))
user.status = UserStatus.BANNED
db_session.flush()

# 验证赠送流水已记录
txn = db_session.scalar(
select(CreditTransaction).where(
CreditTransaction.user_id == user_id,
CreditTransaction.reason == CreditReason.REGISTER_GIFT,
service._redis.get.return_value = "654321"
with pytest.raises(BizException, match="账号已被封禁"):
service.login_by_code_with_session(
db_session,
LoginByCodeInput(email="banned-code@example.com", code="654321"),
)


def test_login_by_code_marks_unverified_email(db_session, service):
user = User(email="unverified@example.com", password_hash=_hash_password("pass123"))
db_session.add(user)
db_session.flush()

service._redis.get.return_value = "123456"
result = service.login_by_code_with_session(
db_session,
LoginByCodeInput(email="unverified@example.com", code="123456"),
)
assert txn is not None
assert txn.delta == quota_settings.register_gift_amount

assert result.user.email == "unverified@example.com"
assert result.user.email_verified_at is not None


def test_login_by_code_existing_user(db_session, service, mock_email):
Expand Down
6 changes: 3 additions & 3 deletions frontend/src/app/layout/app-header.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -176,7 +176,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',
)
Expand All @@ -192,7 +192,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')
})

Expand All @@ -206,7 +206,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 () => {
Expand Down
5 changes: 3 additions & 2 deletions frontend/src/app/layout/app-header.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -190,10 +190,11 @@ export function AppHeader() {
) : session.state.status === 'guest' ? (
<Link
to={accountEntry}
aria-label="登录 / 注册"
aria-label="登录"
className="inline-flex min-h-10 items-center rounded-lg border border-app-ink/14 bg-app-surface-raised/45 px-3 text-[13px] font-medium whitespace-nowrap text-app-ink-soft transition-colors hover:bg-app-surface-raised/75 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-app-accent"
>
<span className="hidden sm:inline">登录 / 注册</span>
{/* 内测关闭公开注册。重新开放时改回「登录 / 注册」。 */}
<span className="hidden sm:inline">登录</span>
<span className="sm:hidden">登录</span>
</Link>
) : (
Expand Down
5 changes: 5 additions & 0 deletions frontend/src/features/account-panel/account-panel.css
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,7 @@
.auth-screen-tab:focus-visible,
.auth-screen-code-action:focus-visible,
.auth-screen-submit:focus-visible,
.auth-screen-entry-switch a:focus-visible,
.auth-screen-entry-switch button:focus-visible,
.auth-password-field button:focus-visible,
.auth-register-back:focus-visible {
Expand Down Expand Up @@ -514,6 +515,7 @@
transform: rotate(-18deg);
}

.auth-screen-entry-switch a,
.auth-screen-entry-switch button {
flex-shrink: 0;
color: #34332e;
Expand Down Expand Up @@ -573,6 +575,7 @@
font-size: 0.875rem;
}

.auth-screen-entry-switch a:hover,
.auth-screen-entry-switch button:hover {
color: #000;
text-underline-offset: 0.3em;
Expand Down Expand Up @@ -965,6 +968,7 @@
.auth-screen-code-action:active:not(:disabled),
.auth-screen-submit:hover:not(:disabled),
.auth-screen-submit:active:not(:disabled),
.auth-screen-entry-switch a:hover,
.auth-screen-entry-switch button:hover {
animation: none;
transform: none;
Expand All @@ -982,6 +986,7 @@
.auth-register-field-shell,
.auth-register-field-shell > svg,
.auth-password-field button,
.auth-screen-entry-switch a,
.auth-screen-entry-switch button {
transition: none;
}
Expand Down
Loading
Loading