fix(api): 修复前后端 API 对接 — 响应结构+字段名对齐

端到端审查发现并修复 4 类前后端不匹配问题:

## 1. 响应结构嵌套不匹配
后端返回 `{ data: { user, tokens: { accessToken, refreshToken } } }`
但 miniapp/admin-app/admin-web 均按扁平结构解析

- miniapp services/auth.ts: 新增 AuthResponse→LoginResult 映射层
- miniapp store/auth.ts: 从 `resp.tokens.accessToken` 取 token
- admin-app auth_service.dart: LoginResult.fromJson 优先从 tokens 子对象取
- admin-web auth-context.tsx: 从 `result.tokens.accessToken` 取 token

## 2. 密码登录字段名不匹配
后端 LoginDto 字段为 `identifier`, 但 admin-app 发 `email`, admin-web 发 `email`

- admin-app: `'email' → 'identifier'`
- admin-web: `{ email, password } → { identifier: email, password }`

## 3. 注册 password 字段必填 vs 前端可选
miniapp h5-register 只收集手机+验证码, 不传 password, 会触发 400 校验

- backend RegisterDto: password 改为 @IsOptional
- auth.service.ts: 未传 password 时自动生成随机密码

## 4. miniapp LoginResult 类型导出
- 导出 LoginResult 接口供外部使用

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
hailin 2026-02-23 21:39:26 -08:00
parent 7d00cade2f
commit e8d9bdc2fb
6 changed files with 67 additions and 33 deletions

View File

@ -77,8 +77,9 @@ export class AuthService {
// Verify SMS code
await this.smsService.verifyCode(dto.phone, dto.smsCode, SmsVerificationType.REGISTER);
// Hash password
const password = await Password.create(dto.password);
// Hash password (if not provided, generate a random one for SMS-only registration)
const rawPassword = dto.password || require('crypto').randomBytes(16).toString('hex');
const password = await Password.create(rawPassword);
// Create user
const user = await this.userRepo.create({

View File

@ -12,11 +12,12 @@ export class RegisterDto {
@Length(6, 6, { message: '验证码必须为6位数字' })
smsCode: string;
@ApiProperty({ description: '登录密码 (8-128位)', example: 'Password123!' })
@ApiPropertyOptional({ description: '登录密码 (8-128位), 不传则后端自动生成', example: 'Password123!' })
@IsOptional()
@IsString()
@MinLength(8)
@MaxLength(128)
password: string;
password?: string;
@ApiPropertyOptional({ description: '昵称', example: 'John' })
@IsOptional()

View File

@ -52,10 +52,10 @@ class AuthService {
}
///
Future<LoginResult> loginByPassword(String email, String password) async {
Future<LoginResult> loginByPassword(String identifier, String password) async {
try {
final response = await _apiClient.post('/api/v1/auth/login', data: {
'email': email,
'identifier': identifier,
'password': password,
});
final data = response.data is Map<String, dynamic> ? response.data : {};
@ -104,9 +104,19 @@ class LoginResult {
});
factory LoginResult.fromJson(Map<String, dynamic> json) {
// { user, tokens: { accessToken, refreshToken, expiresIn } }
final tokens = json['tokens'] as Map<String, dynamic>?;
if (tokens != null) {
return LoginResult(
accessToken: tokens['accessToken'] as String? ?? '',
refreshToken: tokens['refreshToken'] as String?,
user: json['user'] as Map<String, dynamic>?,
);
}
// refresh tokens
return LoginResult(
accessToken: json['accessToken'] ?? '',
refreshToken: json['refreshToken'],
accessToken: json['accessToken'] as String? ?? '',
refreshToken: json['refreshToken'] as String?,
user: json['user'] as Map<String, dynamic>?,
);
}

View File

@ -41,14 +41,17 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
}, []);
const login = useCallback(async (email: string, password: string) => {
const result = await apiClient.post<{ accessToken: string; user: AdminUser }>(
const result = await apiClient.post<{
user: AdminUser;
tokens: { accessToken: string; refreshToken: string; expiresIn: number };
}>(
'/api/v1/auth/login',
{ email, password },
{ identifier: email, password },
);
const { accessToken, user: adminUser } = result;
localStorage.setItem('admin_token', accessToken);
const { tokens, user: adminUser } = result;
localStorage.setItem('admin_token', tokens.accessToken);
localStorage.setItem('admin_user', JSON.stringify(adminUser));
setToken(accessToken);
setToken(tokens.accessToken);
setUser(adminUser);
}, []);

View File

@ -1,22 +1,40 @@
import { post } from '../utils/request';
interface LoginResult {
/** 后端返回的 auth 结构 (request.ts 已自动解包外层 data) */
interface AuthResponse {
user: { id: string; phone?: string; nickname?: string; kycLevel: number };
tokens: { accessToken: string; refreshToken: string; expiresIn: number };
}
/** 前端使用的扁平结构 */
export interface LoginResult {
accessToken: string;
refreshToken: string;
user: { id: string; phone?: string; nickname?: string; kycLevel: number };
}
/** 将后端嵌套结构转为前端扁平结构 */
function toLoginResult(resp: AuthResponse): LoginResult {
return {
accessToken: resp.tokens.accessToken,
refreshToken: resp.tokens.refreshToken,
user: resp.user,
};
}
/** SMS 验证码类型 */
export type SmsCodeType = 'REGISTER' | 'LOGIN' | 'RESET_PASSWORD' | 'CHANGE_PHONE';
/** 手机号+验证码登录 */
export function loginByPhone(phone: string, smsCode: string, deviceInfo?: string) {
return post<LoginResult>('/api/v1/auth/login-phone', { phone, smsCode, ...(deviceInfo ? { deviceInfo } : {}) }, false);
export async function loginByPhone(phone: string, smsCode: string, deviceInfo?: string) {
const resp = await post<AuthResponse>('/api/v1/auth/login-phone', { phone, smsCode, ...(deviceInfo ? { deviceInfo } : {}) }, false);
return toLoginResult(resp);
}
/** 密码登录 */
export function loginByPassword(identifier: string, password: string) {
return post<LoginResult>('/api/v1/auth/login', { identifier, password }, false);
export async function loginByPassword(identifier: string, password: string) {
const resp = await post<AuthResponse>('/api/v1/auth/login', { identifier, password }, false);
return toLoginResult(resp);
}
/** 发送短信验证码 */
@ -25,17 +43,19 @@ export function sendSmsCode(phone: string, type: SmsCodeType = 'LOGIN') {
}
/** 微信登录 */
export function loginByWechat(code: string) {
return post<LoginResult>('/api/v1/auth/login-wechat', { code }, false);
export async function loginByWechat(code: string) {
const resp = await post<AuthResponse>('/api/v1/auth/login-wechat', { code }, false);
return toLoginResult(resp);
}
/** 注册 */
export function register(phone: string, smsCode: string, password?: string, nickname?: string) {
return post<LoginResult>('/api/v1/auth/register', {
export async function register(phone: string, smsCode: string, password?: string, nickname?: string) {
const resp = await post<AuthResponse>('/api/v1/auth/register', {
phone, smsCode,
...(password ? { password } : {}),
...(nickname ? { nickname } : {}),
}, false);
return toLoginResult(resp);
}
/** 重置密码 */

View File

@ -11,10 +11,9 @@ export interface UserProfile {
avatarUrl?: string;
}
interface LoginResult {
accessToken: string;
refreshToken: string;
interface AuthResponse {
user: UserProfile;
tokens: { accessToken: string; refreshToken: string; expiresIn: number };
}
/**
@ -46,10 +45,10 @@ export const authStore = {
/** 手机号+验证码登录 */
async loginByPhone(phone: string, smsCode: string): Promise<void> {
const result = await post<LoginResult>('/api/v1/auth/login-phone', { phone, smsCode }, false);
Taro.setStorageSync(config.TOKEN_KEY, result.accessToken);
Taro.setStorageSync(config.USER_KEY, JSON.stringify(result.user));
_user = result.user;
const resp = await post<AuthResponse>('/api/v1/auth/login-phone', { phone, smsCode }, false);
Taro.setStorageSync(config.TOKEN_KEY, resp.tokens.accessToken);
Taro.setStorageSync(config.USER_KEY, JSON.stringify(resp.user));
_user = resp.user;
notify();
},
@ -61,10 +60,10 @@ export const authStore = {
/** 微信一键登录 */
async loginByWechat(): Promise<void> {
const { code } = await Taro.login();
const result = await post<LoginResult>('/api/v1/auth/login-wechat', { code }, false);
Taro.setStorageSync(config.TOKEN_KEY, result.accessToken);
Taro.setStorageSync(config.USER_KEY, JSON.stringify(result.user));
_user = result.user;
const resp = await post<AuthResponse>('/api/v1/auth/login-wechat', { code }, false);
Taro.setStorageSync(config.TOKEN_KEY, resp.tokens.accessToken);
Taro.setStorageSync(config.USER_KEY, JSON.stringify(resp.user));
_user = resp.user;
notify();
},