Skip to content
Open
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"] == "内测期间暂不开放注册"
45 changes: 14 additions & 31 deletions backend/tests/test_user_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -282,46 +282,29 @@ 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)
)
assert account is not None
assert account.balance == quota_settings.register_gift_amount
assert account.total_earned == quota_settings.register_gift_amount
with pytest.raises(BizException, match="内测期间暂不开放注册"):
service.send_verification_code("new@example.com", "register")

# 验证赠送流水已记录
txn = db_session.scalar(
select(CreditTransaction).where(
CreditTransaction.user_id == user_id,
CreditTransaction.reason == CreditReason.REGISTER_GIFT,
)
)
assert txn is not None
assert txn.delta == quota_settings.register_gift_amount
mock_email.send_verification_code.assert_not_called()


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
4 changes: 2 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,10 @@ 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
118 changes: 19 additions & 99 deletions frontend/src/features/account-panel/index.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -94,29 +94,21 @@ 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.queryByRole('tab', { name: '注册' })).toBeNull()
expect(screen.getByRole('button', { name: '创建账号' })).toBeTruthy()
expect(screen.queryByRole('button', { name: '创建账号' })).toBeNull()
await waitFor(() => expect(document.activeElement).toBe(screen.getByLabelText('邮箱')))
})

it('opens registration as a centered, progressive form', async () => {
renderPanel('/?account=register&returnTo=%2Fworkspace')

const dialog = screen.getByRole('dialog', { name: '创建 Windup 账号' })
expect(screen.getByRole('heading', { name: '欢迎来到 Windup' })).toBeTruthy()
expect(dialog.className).toContain('max-w-[34rem]')
expect(screen.getByText('从一个角色开始,')).toBeTruthy()
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.queryByLabelText('密码')).toBeNull()
expect(screen.queryByLabelText('昵称(选填)')).toBeNull()
expect(screen.queryByLabelText('验证码')).toBeNull()
expect(screen.getByTestId('register-fields').querySelector('[aria-live]')).toBeNull()
expect(screen.getByRole('button', { name: '继续' })).toBeTruthy()
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(apis.register).not.toHaveBeenCalled()
await waitFor(() => expect(document.activeElement).toBe(screen.getByLabelText('邮箱')))
})

Expand All @@ -141,15 +133,15 @@ describe('AccountPanel', () => {

const dialog = screen.getByRole('dialog', { name: '登录 Windup' })
const closeButton = screen.getByRole('button', { name: '关闭账号面板' })
const entrySwitch = screen.getByRole('button', { name: '创建账号' })
const submitButton = screen.getByRole('button', { name: '登录' })

entrySwitch.focus()
fireEvent.keyDown(entrySwitch, { key: 'Tab' })
submitButton.focus()
fireEvent.keyDown(submitButton, { 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(submitButton)
expect(dialog.contains(document.activeElement)).toBe(true)
})

Expand Down Expand Up @@ -266,9 +258,7 @@ describe('AccountPanel', () => {
email: 'reader@example.com',
code: '123456',
})
expect(
screen.getByText('登录成功。如果这是你首次使用该邮箱,我们已为你创建账号。').textContent,
).toContain('登录成功。如果这是你首次使用该邮箱,我们已为你创建账号。')
expect(screen.getByText('登录成功,正在继续。').textContent).toContain('登录成功,正在继续。')

await act(async () => vi.advanceTimersByTimeAsync(900))
expect(screen.getByTestId('location').textContent).toBe('/projects?view=recent')
Expand Down Expand Up @@ -348,85 +338,15 @@ describe('AccountPanel', () => {
expect(apis.sendCode).not.toHaveBeenCalled()
})

it('preserves registration input when showing a password and returning a step', async () => {
renderPanel('/?account=register')
fireEvent.change(screen.getByLabelText('邮箱'), { target: { value: 'new@example.com' } })
fireEvent.submit(screen.getByRole('button', { name: '继续' }).closest('form')!)

const password = await screen.findByLabelText('密码')
expect(password.getAttribute('type')).toBe('password')
fireEvent.click(screen.getByRole('button', { name: '显示密码' }))
expect(password.getAttribute('type')).toBe('text')

fireEvent.click(screen.getByRole('button', { name: '返回上一步' }))
expect(((await screen.findByLabelText('邮箱')) as HTMLInputElement).value).toBe(
'new@example.com',
)
expect(screen.getByTestId('auth-motion-stage').dataset.motionDirection).toBe('backward')
})

it('switches account entry only after the current panel exits', async () => {
it('does not expose a signup switch from the login panel', async () => {
vi.useFakeTimers()
renderPanel('/?account=login&returnTo=%2Fworkspace')

fireEvent.click(screen.getByRole('button', { name: '创建账号' }))
expect(screen.getByRole('dialog', { name: '登录 Windup' })).toBeTruthy()
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=register&returnTo=%2Fworkspace',
)
})

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(screen.getByRole('dialog', { name: '创建 Windup 账号' })).toBeTruthy()
expect(screen.getByRole('heading', { name: '为账号加一道保护' })).toBeTruthy()
expect(await screen.findByLabelText('密码')).toBeTruthy()
fireEvent.change(screen.getByLabelText('密码'), { target: { value: 'short' } })

fireEvent.submit(screen.getByRole('button', { name: '继续' }).closest('form')!)
expect((await screen.findByRole('alert')).textContent).toContain('密码需为 8–128 位')
expect(apis.register).not.toHaveBeenCalled()

fireEvent.change(screen.getByLabelText('密码'), { target: { value: 'password-123' } })
fireEvent.submit(screen.getByRole('button', { name: '继续' }).closest('form')!)
expect(screen.getByRole('dialog', { name: '创建 Windup 账号' })).toBeTruthy()
expect(screen.getByRole('heading', { name: '留下你的称呼' })).toBeTruthy()
expect(await screen.findByLabelText('昵称(选填)')).toBeTruthy()
fireEvent.change(screen.getByLabelText('昵称(选填)'), {
target: { value: 'N'.repeat(51) },
})

fireEvent.submit(screen.getByRole('button', { name: '继续' }).closest('form')!)
expect((await screen.findByRole('alert')).textContent).toContain('昵称不能超过 50 个字符')

fireEvent.change(screen.getByLabelText('昵称(选填)'), { target: { value: '新用户' } })
fireEvent.submit(screen.getByRole('button', { name: '继续' }).closest('form')!)
await waitFor(() =>
expect(apis.sendCode).toHaveBeenCalledWith({
email: 'new@example.com',
purpose: 'register',
}),
)
expect(screen.getByRole('dialog', { name: '创建 Windup 账号' })).toBeTruthy()
expect(screen.getByRole('heading', { name: '确认你的邮箱' })).toBeTruthy()
expect(await screen.findByLabelText('验证码')).toBeTruthy()
fireEvent.change(screen.getByLabelText('验证码'), { target: { value: '123456' } })
fireEvent.submit(screen.getByRole('button', { name: '创建账号' }).closest('form')!)
await waitFor(() =>
expect(apis.register).toHaveBeenCalledWith({
email: 'new@example.com',
password: 'password-123',
code: '123456',
nickname: '新用户',
}),
)
expect(screen.getByRole('dialog', { name: '登录 Windup' })).toBeTruthy()
expect(screen.getByTestId('location').textContent).toBe('/?account=login&returnTo=%2Fworkspace')
})
})

Expand Down
Loading
Loading