From b9e6055400b8d1a6305a973bf0fea08e10c622da Mon Sep 17 00:00:00 2001 From: wangdl Date: Wed, 27 May 2026 21:03:15 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20H0-01=20=E5=BD=BB=E5=BA=95=E9=98=BB?= =?UTF-8?q?=E6=96=AD=E7=94=9F=E4=BA=A7=E7=8E=AF=E5=A2=83=20mock=20+=20?= =?UTF-8?q?=E7=BB=93=E6=9E=84=E5=8C=96=E9=94=99=E8=AF=AF=E7=A0=81=20+=20iO?= =?UTF-8?q?S=20Auth=20=E5=90=88=E5=90=8C=E6=96=87=E6=A1=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - apple-auth.service.ts: verifyIdentityToken 增加 NODE_ENV 检查, 生产环境缺 APPLE_BUNDLE_ID 时运行时返回 401,不再走 mock - 新增 CAPIErrorCode 语义错误码体系 (src/common/errors/) - 新增 CapiException 携带 errorCode 的 HttpException 子类 - GlobalExceptionFilter 响应自动包含 errorCode 字段 - AuthService/JwtAuthGuard/AppleAuthService 全部改用 CapiException - 新增 LoginResponseDto/RefreshResponseDto/LogoutResponseDto/UserDto - Auth controller Swagger 添加 type 参数 - 新增 docs/ios-auth-api-contract.md Co-Authored-By: Claude Opus 4.7 --- docs/ios-auth-api-contract.md | 154 ++++++++++++++++++ src/common/errors/capi-error-codes.ts | 40 +++++ src/common/errors/capi.exception.ts | 19 +++ src/common/filters/global-exception.filter.ts | 16 +- src/common/guards/jwt-auth.guard.ts | 15 +- src/modules/auth/apple-auth.service.ts | 26 ++- src/modules/auth/auth.controller.ts | 9 +- src/modules/auth/auth.service.ts | 21 ++- src/modules/auth/dto/auth-response.dto.ts | 50 ++++++ src/modules/auth/dto/index.ts | 1 + 10 files changed, 322 insertions(+), 29 deletions(-) create mode 100644 docs/ios-auth-api-contract.md create mode 100644 src/common/errors/capi-error-codes.ts create mode 100644 src/common/errors/capi.exception.ts create mode 100644 src/modules/auth/dto/auth-response.dto.ts diff --git a/docs/ios-auth-api-contract.md b/docs/ios-auth-api-contract.md new file mode 100644 index 0000000..9e45100 --- /dev/null +++ b/docs/ios-auth-api-contract.md @@ -0,0 +1,154 @@ +# iOS Auth API Contract + +> 冻结日期:2026-05-27 | 版本:1.0 | 未经评审不得修改请求/响应字段 + +## 1. 基础约定 + +| 项目 | 值 | +|------|-----| +| Base URL(生产) | `https://api.longde.cloud` | +| Content-Type | `application/json` | +| 认证方式 | `Authorization: Bearer ` | +| 成功响应格式 | `{ success: true, data: , timestamp: "ISO8601" }` | +| 错误响应格式 | `{ success: false, statusCode: , message: "<中文>", errorCode: "<语义码>" }` | + +## 2. Token 生命周期 + +| Token | 有效期 | 存储位置 | +|-------|--------|----------| +| accessToken (JWT) | 1 小时 | Keychain | +| refreshToken (opaque) | 7 天 | Keychain | + +- refreshToken 是一次性的:每次 `/auth/refresh` 成功后旧的立即吊销,返回新的 +- accessToken 过期 → iOS 用 refreshToken 换新,不要重新走 Apple 登录 +- refreshToken 过期 → 回到登录页 + +## 3. 接口清单 + +### 3.1 Apple 登录 + +``` +POST /auth/apple +``` + +**请求体:** + +| 字段 | 类型 | 必需 | 说明 | +|------|------|------|------| +| identityToken | string | 是 | Apple 返回的 JWT identityToken | +| authorizationCode | string | 否 | Apple 返回的授权码(建议传) | +| nonce | string | 否 | iOS 生成的原始 nonce(未哈希) | +| fullName.givenName | string | 否 | 用户的名 | +| fullName.familyName | string | 否 | 用户的姓 | +| email | string | 否 | Apple 返回的邮箱 | + +**成功响应 `data`:** + +| 字段 | 类型 | 说明 | +|------|------|------| +| accessToken | string | JWT,含 type: "user" | +| refreshToken | string | 96 位十六进制字符串 | +| user.id | string | 用户 ID | +| user.email | string\|null | 邮箱 | +| user.nickname | string\|null | 昵称 | +| user.avatarUrl | string\|null | 头像 URL | +| user.role | string | 角色 | +| user.status | string | 状态 | +| user.onboardingCompleted | boolean | 是否完成引导 | + +**错误:** + +| errorCode | HTTP | 说明 | +|-----------|------|------| +| AUTH_INVALID_APPLE_TOKEN | 401 | identityToken 无效、过期或验证失败 | + +### 3.2 刷新 Token + +``` +POST /auth/refresh +``` + +**请求体:** + +| 字段 | 类型 | 必需 | +|------|------|------| +| refreshToken | string | 是 | + +**成功响应 `data`:** 同登录响应(新 accessToken + 新 refreshToken + user) + +**错误:** + +| errorCode | HTTP | 说明 | +|-----------|------|------| +| AUTH_REFRESH_TOKEN_EXPIRED | 401 | 超过 7 天未使用 | +| AUTH_REFRESH_TOKEN_REVOKED | 401 | 已被登出/安全事件撤销 | +| AUTH_USER_DISABLED | 401 | 账号被管理员禁用 | +| AUTH_USER_DELETED | 401 | 账号已注销 | + +### 3.3 获取当前用户 + +``` +GET /users/me +``` + +需要 Bearer token。 + +**成功响应 `data`:** 用户对象(同登录响应中的 user) + +**错误:** + +| errorCode | HTTP | 说明 | +|-----------|------|------| +| AUTH_UNAUTHORIZED | 401 | 未登录或 token 过期 | +| AUTH_USER_DISABLED | 401 | 账号被禁用 | +| AUTH_USER_DELETED | 401 | 账号已注销 | +| AUTH_WRONG_TOKEN_TYPE | 401 | 使用了 admin token | + +### 3.4 登出 + +``` +POST /auth/logout +``` + +需要 Bearer token。 + +**请求体:** + +| 字段 | 类型 | 必需 | +|------|------|------| +| refreshToken | string | 是 | + +**成功响应:** `{ success: true, message: "已退出登录" }` + +**错误:** + +| errorCode | HTTP | 说明 | +|-----------|------|------| +| AUTH_UNAUTHORIZED | 401 | token 已过期(不影响客户端清本地状态) | + +## 4. 完整错误码表 + +| errorCode | 含义 | iOS 处理策略 | +|-----------|------|-------------| +| AUTH_INVALID_APPLE_TOKEN | Apple token 验证失败 | 提示用户重试 Apple 登录 | +| AUTH_USER_DISABLED | 账号被禁用 | 清空本地 session,显示禁用提示 | +| AUTH_USER_DELETED | 账号已注销 | 清空本地 session,回到欢迎页 | +| AUTH_REFRESH_TOKEN_EXPIRED | Refresh token 过期 | 清空本地 session,回到登录页 | +| AUTH_REFRESH_TOKEN_REVOKED | Refresh token 被撤销 | 清空本地 session,回到登录页 | +| AUTH_UNAUTHORIZED | 未认证 | 尝试 refresh,失败则回登录页 | +| AUTH_WRONG_TOKEN_TYPE | Token 类型错误 | 清空本地 session,重新登录 | +| AUTH_DEV_LOGIN_FORBIDDEN | 生产环境禁用 dev 登录 | 不触发(仅 iOS 不关心) | +| VALIDATION_ERROR | 请求参数校验失败 | 检查发送的字段 | +| NOT_FOUND | 资源未找到 | 提示用户 | +| FORBIDDEN | 权限不足 | 提示用户 | +| RATE_LIMITED | 请求过快 | 稍后重试 | +| INTERNAL_ERROR | 服务器错误 | 提示用户稍后重试 | + +## 5. iOS 实现要点 + +1. **Token 存储** — 用 Keychain(不是 UserDefaults) +2. **401 自动刷新** — APIClient 拦截 401,用 refreshToken 换新 accessToken,失败则清 session +3. **并发刷新** — 多个请求同时 401 时只发一次 refresh +4. **AppSession 状态** — 维护状态机:`unauthenticated → authenticating → authenticated → refreshing/expired/disabled/deleted` +5. **Apple 登录 nonce** — 用 `SecRandomCopyBytes` 生成,SHA256 后传给 Apple,原始值传给后端 +6. **authorizationCode** — 提取并传给后端 diff --git a/src/common/errors/capi-error-codes.ts b/src/common/errors/capi-error-codes.ts new file mode 100644 index 0000000..5f4241b --- /dev/null +++ b/src/common/errors/capi-error-codes.ts @@ -0,0 +1,40 @@ +/** + * CAPI 语义错误码 — 前后端共享契约 + * + * 所有 CAPI 错误响应均包含 `errorCode` 字段,iOS 端据此做强类型分支判断, + * 不依赖中文 message 字符串。 + */ + +export const CAPIErrorCode = { + // ── Auth 认证 ── + /** Apple identityToken 无效或验证失败 */ + AUTH_INVALID_APPLE_TOKEN: 'AUTH_INVALID_APPLE_TOKEN', + /** 用户已被禁用 */ + AUTH_USER_DISABLED: 'AUTH_USER_DISABLED', + /** 用户已注销/删除 */ + AUTH_USER_DELETED: 'AUTH_USER_DELETED', + /** Refresh token 已过期(7 天未使用) */ + AUTH_REFRESH_TOKEN_EXPIRED: 'AUTH_REFRESH_TOKEN_EXPIRED', + /** Refresh token 已被撤销(登出或安全事件) */ + AUTH_REFRESH_TOKEN_REVOKED: 'AUTH_REFRESH_TOKEN_REVOKED', + /** 未登录或 access token 过期 */ + AUTH_UNAUTHORIZED: 'AUTH_UNAUTHORIZED', + /** 使用了 admin token 访问用户端点(或反之) */ + AUTH_WRONG_TOKEN_TYPE: 'AUTH_WRONG_TOKEN_TYPE', + /** 开发登录在生产环境被禁用 */ + AUTH_DEV_LOGIN_FORBIDDEN: 'AUTH_DEV_LOGIN_FORBIDDEN', + + // ── 通用 ── + /** 请求参数校验失败 */ + VALIDATION_ERROR: 'VALIDATION_ERROR', + /** 资源未找到 */ + NOT_FOUND: 'NOT_FOUND', + /** 权限不足 */ + FORBIDDEN: 'FORBIDDEN', + /** 请求过于频繁 */ + RATE_LIMITED: 'RATE_LIMITED', + /** 服务器内部错误 */ + INTERNAL_ERROR: 'INTERNAL_ERROR', +} as const; + +export type CAPIErrorCode = (typeof CAPIErrorCode)[keyof typeof CAPIErrorCode]; diff --git a/src/common/errors/capi.exception.ts b/src/common/errors/capi.exception.ts new file mode 100644 index 0000000..347d5bd --- /dev/null +++ b/src/common/errors/capi.exception.ts @@ -0,0 +1,19 @@ +import { HttpException, HttpStatus } from '@nestjs/common'; +import { CAPIErrorCode } from './capi-error-codes'; + +/** + * 携带语义错误码的 HttpException。 + * 用于 CAPI 统一错误响应,iOS 端通过 errorCode 做强类型判断。 + */ +export class CapiException extends HttpException { + readonly errorCode: CAPIErrorCode; + + constructor( + errorCode: CAPIErrorCode, + message: string, + status: HttpStatus = HttpStatus.BAD_REQUEST, + ) { + super({ message, errorCode }, status); + this.errorCode = errorCode; + } +} diff --git a/src/common/filters/global-exception.filter.ts b/src/common/filters/global-exception.filter.ts index 5356daf..5d3bfd5 100644 --- a/src/common/filters/global-exception.filter.ts +++ b/src/common/filters/global-exception.filter.ts @@ -24,14 +24,21 @@ export class GlobalExceptionFilter implements ExceptionFilter { let status = HttpStatus.INTERNAL_SERVER_ERROR; let message = '服务器内部错误'; + let errorCode: string | undefined; if (exception instanceof HttpException) { status = exception.getStatus(); const exceptionResponse = exception.getResponse(); - message = - typeof exceptionResponse === 'string' - ? exceptionResponse - : (exceptionResponse as any).message || exception.message; + + if (typeof exceptionResponse === 'object' && exceptionResponse !== null) { + const resp = exceptionResponse as Record; + errorCode = resp['errorCode'] as string | undefined; + message = + (resp['message'] as string) || exception.message; + } else { + message = String(exceptionResponse); + } + if (Array.isArray(message)) message = message.join('; '); } @@ -46,6 +53,7 @@ export class GlobalExceptionFilter implements ExceptionFilter { success: false, statusCode: status, message, + ...(errorCode ? { errorCode } : {}), ...(isProduction ? {} : { path: request.url }), }); } diff --git a/src/common/guards/jwt-auth.guard.ts b/src/common/guards/jwt-auth.guard.ts index 4ba17da..65dae14 100644 --- a/src/common/guards/jwt-auth.guard.ts +++ b/src/common/guards/jwt-auth.guard.ts @@ -10,6 +10,9 @@ import { ConfigService } from '@nestjs/config'; import { PrismaService } from '../../infrastructure/database/prisma.service'; import { Request } from 'express'; import { IS_PUBLIC_KEY } from '../decorators/public.decorator'; +import { CapiException } from '../errors/capi.exception'; +import { CAPIErrorCode } from '../errors/capi-error-codes'; +import { HttpStatus } from '@nestjs/common'; @Injectable() export class JwtAuthGuard implements CanActivate { @@ -37,7 +40,7 @@ export class JwtAuthGuard implements CanActivate { const token = this.extractToken(request); if (!token) { - throw new UnauthorizedException('请先登录'); + throw new CapiException(CAPIErrorCode.AUTH_UNAUTHORIZED, '请先登录', HttpStatus.UNAUTHORIZED); } try { @@ -47,7 +50,7 @@ export class JwtAuthGuard implements CanActivate { // Reject admin tokens on user endpoints if (payload.type === 'admin') { - throw new UnauthorizedException('无效的访问令牌'); + throw new CapiException(CAPIErrorCode.AUTH_WRONG_TOKEN_TYPE, '无效的访问令牌', HttpStatus.UNAUTHORIZED); } const user = await this.prisma.user.findUnique({ @@ -56,18 +59,18 @@ export class JwtAuthGuard implements CanActivate { }); if (!user || user.deletedAt) { - throw new UnauthorizedException('账号不存在或已注销'); + throw new CapiException(CAPIErrorCode.AUTH_USER_DELETED, '账号不存在或已注销', HttpStatus.UNAUTHORIZED); } if (user.status !== 'active') { - throw new UnauthorizedException('账号已被禁用'); + throw new CapiException(CAPIErrorCode.AUTH_USER_DISABLED, '账号已被禁用', HttpStatus.UNAUTHORIZED); } request.user = { id: user.id, email: user.email, role: user.role }; return true; } catch (err) { - if (err instanceof UnauthorizedException) throw err; - throw new UnauthorizedException('登录已过期,请重新登录'); + if (err instanceof CapiException || err instanceof UnauthorizedException) throw err; + throw new CapiException(CAPIErrorCode.AUTH_UNAUTHORIZED, '登录已过期,请重新登录', HttpStatus.UNAUTHORIZED); } } diff --git a/src/modules/auth/apple-auth.service.ts b/src/modules/auth/apple-auth.service.ts index 25c6148..6fcdf62 100644 --- a/src/modules/auth/apple-auth.service.ts +++ b/src/modules/auth/apple-auth.service.ts @@ -1,7 +1,10 @@ import * as crypto from 'crypto'; -import { Injectable, UnauthorizedException, Logger, OnModuleInit } from '@nestjs/common'; +import { Injectable, Logger, OnModuleInit } from '@nestjs/common'; +import { HttpStatus } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; import { createRemoteJWKSet, jwtVerify } from 'jose'; +import { CapiException } from '../../common/errors/capi.exception'; +import { CAPIErrorCode } from '../../common/errors/capi-error-codes'; @Injectable() export class AppleAuthService implements OnModuleInit { @@ -54,7 +57,16 @@ export class AppleAuthService implements OnModuleInit { email?: string; emailVerified?: boolean; }> { + const nodeEnv = process.env.NODE_ENV; + if (!this.appleBundleId) { + if (nodeEnv === 'production') { + throw new CapiException( + CAPIErrorCode.AUTH_INVALID_APPLE_TOKEN, + 'Apple 登录未配置,请联系管理员', + HttpStatus.UNAUTHORIZED, + ); + } return this.verifyMock(identityToken); } return this.verifyReal(identityToken, rawNonce); @@ -66,7 +78,7 @@ export class AppleAuthService implements OnModuleInit { emailVerified?: boolean; } { if (!identityToken || identityToken.trim().length < 4) { - throw new UnauthorizedException('identityToken 无效'); + throw new CapiException(CAPIErrorCode.AUTH_INVALID_APPLE_TOKEN, 'identityToken 无效', HttpStatus.UNAUTHORIZED); } const mockUserId = crypto @@ -111,17 +123,15 @@ export class AppleAuthService implements OnModuleInit { } catch (err: any) { const msg: string = err?.message ?? ''; if (msg.includes('audience')) { - throw new UnauthorizedException( - `identityToken audience 不匹配,期望 ${this.appleBundleId}`, - ); + throw new CapiException(CAPIErrorCode.AUTH_INVALID_APPLE_TOKEN, `identityToken audience 不匹配,期望 ${this.appleBundleId}`, HttpStatus.UNAUTHORIZED); } if (msg.includes('issuer')) { - throw new UnauthorizedException('identityToken issuer 无效'); + throw new CapiException(CAPIErrorCode.AUTH_INVALID_APPLE_TOKEN, 'identityToken issuer 无效', HttpStatus.UNAUTHORIZED); } if (msg.includes('nonce')) { - throw new UnauthorizedException('identityToken nonce 验证失败'); + throw new CapiException(CAPIErrorCode.AUTH_INVALID_APPLE_TOKEN, 'identityToken nonce 验证失败', HttpStatus.UNAUTHORIZED); } - throw new UnauthorizedException('identityToken 验证失败'); + throw new CapiException(CAPIErrorCode.AUTH_INVALID_APPLE_TOKEN, 'identityToken 验证失败', HttpStatus.UNAUTHORIZED); } } diff --git a/src/modules/auth/auth.controller.ts b/src/modules/auth/auth.controller.ts index ec85fa8..c3acaae 100644 --- a/src/modules/auth/auth.controller.ts +++ b/src/modules/auth/auth.controller.ts @@ -2,6 +2,7 @@ import { ApiTags, ApiOperation, ApiResponse } from '@nestjs/swagger'; import { Controller, Post, Body, HttpCode, HttpStatus, Req } from '@nestjs/common'; import { AuthService } from './auth.service'; import { AppleLoginDto, DevLoginDto, RefreshDto } from './dto'; +import { LoginResponseDto, RefreshResponseDto, LogoutResponseDto } from './dto/auth-response.dto'; import { Public } from '../../common/decorators/public.decorator'; import { LoginRateLimit } from '../../common/decorators/rate-limit.decorator'; import type { Request } from 'express'; @@ -16,7 +17,7 @@ export class AuthController { @HttpCode(HttpStatus.OK) @LoginRateLimit() @ApiOperation({ summary: '开发登录(仅非生产环境)' }) - @ApiResponse({ status: 200, description: '登录成功' }) + @ApiResponse({ status: 200, description: '登录成功', type: LoginResponseDto }) @ApiResponse({ status: 403, description: '生产环境禁用' }) async devLogin(@Body() dto: DevLoginDto) { return this.authService.devLogin(dto); @@ -27,7 +28,7 @@ export class AuthController { @HttpCode(HttpStatus.OK) @LoginRateLimit() @ApiOperation({ summary: 'Apple 登录' }) - @ApiResponse({ status: 200, description: '登录成功' }) + @ApiResponse({ status: 200, description: '登录成功', type: LoginResponseDto }) @ApiResponse({ status: 401, description: '身份验证失败' }) async appleLogin(@Body() dto: AppleLoginDto) { return this.authService.appleLogin(dto); @@ -37,7 +38,7 @@ export class AuthController { @Post('refresh') @HttpCode(HttpStatus.OK) @ApiOperation({ summary: '刷新令牌' }) - @ApiResponse({ status: 200, description: '刷新成功' }) + @ApiResponse({ status: 200, description: '刷新成功', type: RefreshResponseDto }) @ApiResponse({ status: 401, description: '刷新令牌无效' }) async refresh(@Body() dto: RefreshDto) { return this.authService.refresh(dto.refreshToken); @@ -46,7 +47,7 @@ export class AuthController { @Post('logout') @HttpCode(HttpStatus.OK) @ApiOperation({ summary: '退出登录' }) - @ApiResponse({ status: 200, description: '退出成功' }) + @ApiResponse({ status: 200, description: '退出成功', type: LogoutResponseDto }) @ApiResponse({ status: 401, description: '未登录' }) async logout(@Req() req: Request, @Body() dto: RefreshDto) { const user = (req as any).user; diff --git a/src/modules/auth/auth.service.ts b/src/modules/auth/auth.service.ts index c381fd0..e4b095d 100644 --- a/src/modules/auth/auth.service.ts +++ b/src/modules/auth/auth.service.ts @@ -1,7 +1,10 @@ -import { Injectable, ForbiddenException, UnauthorizedException } from '@nestjs/common'; +import { Injectable } from '@nestjs/common'; +import { HttpStatus } from '@nestjs/common'; import { PrismaService } from '../../infrastructure/database/prisma.service'; import { AppleAuthService } from './apple-auth.service'; import { TokenService } from './token.service'; +import { CapiException } from '../../common/errors/capi.exception'; +import { CAPIErrorCode } from '../../common/errors/capi-error-codes'; import type { AppleLoginDto, DevLoginDto } from './dto'; @Injectable() @@ -14,12 +17,12 @@ export class AuthService { async devLogin(dto: DevLoginDto) { if (process.env.NODE_ENV === 'production') { - throw new ForbiddenException('dev-login is disabled in production'); + throw new CapiException(CAPIErrorCode.AUTH_DEV_LOGIN_FORBIDDEN, 'dev-login is disabled in production', HttpStatus.FORBIDDEN); } const devSecret = process.env.DEV_SECRET; if (!devSecret || dto.devSecret !== devSecret) { - throw new UnauthorizedException('devSecret 无效'); + throw new CapiException(CAPIErrorCode.AUTH_UNAUTHORIZED, 'devSecret 无效', HttpStatus.UNAUTHORIZED); } const providerUserId = dto.email; @@ -128,18 +131,22 @@ export class AuthService { include: { user: true }, }); - if (!stored || stored.expiresAt < new Date()) { - throw new UnauthorizedException('刷新令牌无效或已过期'); + if (!stored) { + throw new CapiException(CAPIErrorCode.AUTH_REFRESH_TOKEN_REVOKED, '刷新令牌已失效', HttpStatus.UNAUTHORIZED); + } + + if (stored.expiresAt < new Date()) { + throw new CapiException(CAPIErrorCode.AUTH_REFRESH_TOKEN_EXPIRED, '刷新令牌已过期,请重新登录', HttpStatus.UNAUTHORIZED); } // Check user status before issuing new tokens if (stored.user.deletedAt) { await this.revokeAllUserTokens(stored.userId); - throw new UnauthorizedException('账号已注销'); + throw new CapiException(CAPIErrorCode.AUTH_USER_DELETED, '账号已注销', HttpStatus.UNAUTHORIZED); } if (stored.user.status !== 'active') { - throw new UnauthorizedException('账号已被禁用'); + throw new CapiException(CAPIErrorCode.AUTH_USER_DISABLED, '账号已被禁用', HttpStatus.UNAUTHORIZED); } await this.prisma.refreshToken.update({ diff --git a/src/modules/auth/dto/auth-response.dto.ts b/src/modules/auth/dto/auth-response.dto.ts new file mode 100644 index 0000000..ff0540d --- /dev/null +++ b/src/modules/auth/dto/auth-response.dto.ts @@ -0,0 +1,50 @@ +import { ApiProperty } from '@nestjs/swagger'; + +export class UserDto { + @ApiProperty({ example: 'clx...' }) + id: string; + + @ApiProperty({ example: 'user@example.com', nullable: true }) + email: string | null; + + @ApiProperty({ example: '张三', nullable: true }) + nickname: string | null; + + @ApiProperty({ example: null, nullable: true }) + avatarUrl: string | null; + + @ApiProperty({ example: 'user' }) + role: string; + + @ApiProperty({ example: 'active' }) + status: string; + + @ApiProperty({ example: false }) + onboardingCompleted: boolean; +} + +export class AuthTokensDto { + @ApiProperty({ description: 'JWT access token,有效期 1h', example: 'eyJhbG...' }) + accessToken: string; + + @ApiProperty({ description: '96 位十六进制 refresh token,一次性轮换', example: 'a1b2c3...' }) + refreshToken: string; +} + +export class LoginResponseDto extends AuthTokensDto { + @ApiProperty({ type: UserDto }) + user: UserDto; +} + +export class RefreshResponseDto extends AuthTokensDto { + @ApiProperty({ type: UserDto }) + user: UserDto; +} + +export class LogoutResponseDto { + @ApiProperty({ example: true }) + success: boolean; + + @ApiProperty({ example: '已退出登录' }) + message: string; +} diff --git a/src/modules/auth/dto/index.ts b/src/modules/auth/dto/index.ts index 12e3488..ead64ce 100644 --- a/src/modules/auth/dto/index.ts +++ b/src/modules/auth/dto/index.ts @@ -1,3 +1,4 @@ export { AppleLoginDto } from './apple-login.dto'; export { DevLoginDto } from './dev-login.dto'; export { RefreshDto } from './refresh-token.dto'; +export { LoginResponseDto, RefreshResponseDto, LogoutResponseDto, UserDto } from './auth-response.dto';