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:
parent
7d00cade2f
commit
e8d9bdc2fb
|
|
@ -77,8 +77,9 @@ export class AuthService {
|
||||||
// Verify SMS code
|
// Verify SMS code
|
||||||
await this.smsService.verifyCode(dto.phone, dto.smsCode, SmsVerificationType.REGISTER);
|
await this.smsService.verifyCode(dto.phone, dto.smsCode, SmsVerificationType.REGISTER);
|
||||||
|
|
||||||
// Hash password
|
// Hash password (if not provided, generate a random one for SMS-only registration)
|
||||||
const password = await Password.create(dto.password);
|
const rawPassword = dto.password || require('crypto').randomBytes(16).toString('hex');
|
||||||
|
const password = await Password.create(rawPassword);
|
||||||
|
|
||||||
// Create user
|
// Create user
|
||||||
const user = await this.userRepo.create({
|
const user = await this.userRepo.create({
|
||||||
|
|
|
||||||
|
|
@ -12,11 +12,12 @@ export class RegisterDto {
|
||||||
@Length(6, 6, { message: '验证码必须为6位数字' })
|
@Length(6, 6, { message: '验证码必须为6位数字' })
|
||||||
smsCode: string;
|
smsCode: string;
|
||||||
|
|
||||||
@ApiProperty({ description: '登录密码 (8-128位)', example: 'Password123!' })
|
@ApiPropertyOptional({ description: '登录密码 (8-128位), 不传则后端自动生成', example: 'Password123!' })
|
||||||
|
@IsOptional()
|
||||||
@IsString()
|
@IsString()
|
||||||
@MinLength(8)
|
@MinLength(8)
|
||||||
@MaxLength(128)
|
@MaxLength(128)
|
||||||
password: string;
|
password?: string;
|
||||||
|
|
||||||
@ApiPropertyOptional({ description: '昵称', example: 'John' })
|
@ApiPropertyOptional({ description: '昵称', example: 'John' })
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
|
|
|
||||||
|
|
@ -52,10 +52,10 @@ class AuthService {
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 密码登录
|
/// 密码登录
|
||||||
Future<LoginResult> loginByPassword(String email, String password) async {
|
Future<LoginResult> loginByPassword(String identifier, String password) async {
|
||||||
try {
|
try {
|
||||||
final response = await _apiClient.post('/api/v1/auth/login', data: {
|
final response = await _apiClient.post('/api/v1/auth/login', data: {
|
||||||
'email': email,
|
'identifier': identifier,
|
||||||
'password': password,
|
'password': password,
|
||||||
});
|
});
|
||||||
final data = response.data is Map<String, dynamic> ? response.data : {};
|
final data = response.data is Map<String, dynamic> ? response.data : {};
|
||||||
|
|
@ -104,9 +104,19 @@ class LoginResult {
|
||||||
});
|
});
|
||||||
|
|
||||||
factory LoginResult.fromJson(Map<String, dynamic> json) {
|
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(
|
return LoginResult(
|
||||||
accessToken: json['accessToken'] ?? '',
|
accessToken: json['accessToken'] as String? ?? '',
|
||||||
refreshToken: json['refreshToken'],
|
refreshToken: json['refreshToken'] as String?,
|
||||||
user: json['user'] as Map<String, dynamic>?,
|
user: json['user'] as Map<String, dynamic>?,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -41,14 +41,17 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const login = useCallback(async (email: string, password: string) => {
|
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',
|
'/api/v1/auth/login',
|
||||||
{ email, password },
|
{ identifier: email, password },
|
||||||
);
|
);
|
||||||
const { accessToken, user: adminUser } = result;
|
const { tokens, user: adminUser } = result;
|
||||||
localStorage.setItem('admin_token', accessToken);
|
localStorage.setItem('admin_token', tokens.accessToken);
|
||||||
localStorage.setItem('admin_user', JSON.stringify(adminUser));
|
localStorage.setItem('admin_user', JSON.stringify(adminUser));
|
||||||
setToken(accessToken);
|
setToken(tokens.accessToken);
|
||||||
setUser(adminUser);
|
setUser(adminUser);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,22 +1,40 @@
|
||||||
import { post } from '../utils/request';
|
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;
|
accessToken: string;
|
||||||
refreshToken: string;
|
refreshToken: string;
|
||||||
user: { id: string; phone?: string; nickname?: string; kycLevel: number };
|
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 验证码类型 */
|
/** SMS 验证码类型 */
|
||||||
export type SmsCodeType = 'REGISTER' | 'LOGIN' | 'RESET_PASSWORD' | 'CHANGE_PHONE';
|
export type SmsCodeType = 'REGISTER' | 'LOGIN' | 'RESET_PASSWORD' | 'CHANGE_PHONE';
|
||||||
|
|
||||||
/** 手机号+验证码登录 */
|
/** 手机号+验证码登录 */
|
||||||
export function loginByPhone(phone: string, smsCode: string, deviceInfo?: string) {
|
export async function loginByPhone(phone: string, smsCode: string, deviceInfo?: string) {
|
||||||
return post<LoginResult>('/api/v1/auth/login-phone', { phone, smsCode, ...(deviceInfo ? { deviceInfo } : {}) }, false);
|
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) {
|
export async function loginByPassword(identifier: string, password: string) {
|
||||||
return post<LoginResult>('/api/v1/auth/login', { identifier, password }, false);
|
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) {
|
export async function loginByWechat(code: string) {
|
||||||
return post<LoginResult>('/api/v1/auth/login-wechat', { code }, false);
|
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) {
|
export async function register(phone: string, smsCode: string, password?: string, nickname?: string) {
|
||||||
return post<LoginResult>('/api/v1/auth/register', {
|
const resp = await post<AuthResponse>('/api/v1/auth/register', {
|
||||||
phone, smsCode,
|
phone, smsCode,
|
||||||
...(password ? { password } : {}),
|
...(password ? { password } : {}),
|
||||||
...(nickname ? { nickname } : {}),
|
...(nickname ? { nickname } : {}),
|
||||||
}, false);
|
}, false);
|
||||||
|
return toLoginResult(resp);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 重置密码 */
|
/** 重置密码 */
|
||||||
|
|
|
||||||
|
|
@ -11,10 +11,9 @@ export interface UserProfile {
|
||||||
avatarUrl?: string;
|
avatarUrl?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface LoginResult {
|
interface AuthResponse {
|
||||||
accessToken: string;
|
|
||||||
refreshToken: string;
|
|
||||||
user: UserProfile;
|
user: UserProfile;
|
||||||
|
tokens: { accessToken: string; refreshToken: string; expiresIn: number };
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -46,10 +45,10 @@ export const authStore = {
|
||||||
|
|
||||||
/** 手机号+验证码登录 */
|
/** 手机号+验证码登录 */
|
||||||
async loginByPhone(phone: string, smsCode: string): Promise<void> {
|
async loginByPhone(phone: string, smsCode: string): Promise<void> {
|
||||||
const result = await post<LoginResult>('/api/v1/auth/login-phone', { phone, smsCode }, false);
|
const resp = await post<AuthResponse>('/api/v1/auth/login-phone', { phone, smsCode }, false);
|
||||||
Taro.setStorageSync(config.TOKEN_KEY, result.accessToken);
|
Taro.setStorageSync(config.TOKEN_KEY, resp.tokens.accessToken);
|
||||||
Taro.setStorageSync(config.USER_KEY, JSON.stringify(result.user));
|
Taro.setStorageSync(config.USER_KEY, JSON.stringify(resp.user));
|
||||||
_user = result.user;
|
_user = resp.user;
|
||||||
notify();
|
notify();
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|
@ -61,10 +60,10 @@ export const authStore = {
|
||||||
/** 微信一键登录 */
|
/** 微信一键登录 */
|
||||||
async loginByWechat(): Promise<void> {
|
async loginByWechat(): Promise<void> {
|
||||||
const { code } = await Taro.login();
|
const { code } = await Taro.login();
|
||||||
const result = await post<LoginResult>('/api/v1/auth/login-wechat', { code }, false);
|
const resp = await post<AuthResponse>('/api/v1/auth/login-wechat', { code }, false);
|
||||||
Taro.setStorageSync(config.TOKEN_KEY, result.accessToken);
|
Taro.setStorageSync(config.TOKEN_KEY, resp.tokens.accessToken);
|
||||||
Taro.setStorageSync(config.USER_KEY, JSON.stringify(result.user));
|
Taro.setStorageSync(config.USER_KEY, JSON.stringify(resp.user));
|
||||||
_user = result.user;
|
_user = resp.user;
|
||||||
notify();
|
notify();
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue