@@ -16,6 +16,7 @@ type TokenCache = { accessToken: string; expiresAt: number };
|
||||
type TicketCache = { ticket: string; expiresAt: number };
|
||||
|
||||
const ACCESS_TOKEN_KEY = 'wechat:access_token';
|
||||
const MINI_ACCESS_TOKEN_KEY = 'wechat:mini_access_token';
|
||||
const JSAPI_TICKET_KEY = 'wechat:jsapi_ticket';
|
||||
|
||||
@Injectable()
|
||||
@@ -255,7 +256,7 @@ export class WechatApiProvider implements IWechatProvider {
|
||||
if (platform === 'h5') {
|
||||
throw new InternalServerErrorException('H5 请使用短信绑定手机号');
|
||||
}
|
||||
const accessToken = await this.getAccessToken();
|
||||
const accessToken = await this.getMiniAccessToken();
|
||||
const apiUrl = `https://api.weixin.qq.com/wxa/business/getuserphonenumber?access_token=${accessToken}`;
|
||||
const data = await this.fetchJson<{
|
||||
errcode?: number;
|
||||
@@ -433,6 +434,37 @@ export class WechatApiProvider implements IWechatProvider {
|
||||
return data.access_token;
|
||||
}
|
||||
|
||||
/** 小程序 access_token(getPhoneNumber 等 wxa 接口必须用小程序 AppID) */
|
||||
private async getMiniAccessToken(): Promise<string> {
|
||||
const appId = this.miniAppId;
|
||||
const appSecret = this.miniAppSecret;
|
||||
if (!appId || !appSecret) {
|
||||
throw new InternalServerErrorException(
|
||||
'小程序未配置:请设置 WX_MINI_APP_ID / WX_MINI_APP_SECRET',
|
||||
);
|
||||
}
|
||||
const cached = await this.redis.getJson<TokenCache>(MINI_ACCESS_TOKEN_KEY);
|
||||
if (cached && cached.expiresAt > Date.now()) return cached.accessToken;
|
||||
|
||||
const url = new URL('https://api.weixin.qq.com/cgi-bin/token');
|
||||
url.searchParams.set('grant_type', 'client_credential');
|
||||
url.searchParams.set('appid', appId);
|
||||
url.searchParams.set('secret', appSecret);
|
||||
const data = await this.fetchJson<{ access_token?: string; expires_in?: number; errcode?: number; errmsg?: string }>(
|
||||
url.toString(),
|
||||
);
|
||||
if (!data.access_token) {
|
||||
throw new InternalServerErrorException(data.errmsg || '获取小程序 access_token 失败');
|
||||
}
|
||||
const ttl = Math.max((data.expires_in ?? 7200) - 300, 60);
|
||||
await this.redis.setJson(
|
||||
MINI_ACCESS_TOKEN_KEY,
|
||||
{ accessToken: data.access_token, expiresAt: Date.now() + ttl * 1000 },
|
||||
ttl,
|
||||
);
|
||||
return data.access_token;
|
||||
}
|
||||
|
||||
private async getJsapiTicket(): Promise<string> {
|
||||
const cached = await this.redis.getJson<TicketCache>(JSAPI_TICKET_KEY);
|
||||
if (cached && cached.expiresAt > Date.now()) return cached.ticket;
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
CheckPartnerPhoneDto,
|
||||
LoginSmsDto,
|
||||
LoginWechatDto,
|
||||
LoginWechatPhoneDto,
|
||||
RefreshTokenDto,
|
||||
SendSmsDto,
|
||||
} from './dto/auth.dto';
|
||||
@@ -74,6 +75,23 @@ export class UserAuthController {
|
||||
return this.authService.loginUserWechat(dto.code, clientApp, platform, guestId);
|
||||
}
|
||||
|
||||
/** 小程序手机号快捷登录(getPhoneNumber) */
|
||||
@Post('auth/login/wechat-phone')
|
||||
@UseGuards(OptionalJwtAuthGuard)
|
||||
wechatPhoneLogin(@Req() req: Request, @Body() dto: LoginWechatPhoneDto) {
|
||||
const guest = (req as Request & { user?: AuthUser }).user;
|
||||
const guestId = guest?.actorType === 'USER' ? guest.actorId : undefined;
|
||||
const clientApp = resolveUserClientApp(req);
|
||||
const platform = dto.platform ?? (clientApp === ClientApp.USER_MINI ? 'mini' : 'h5');
|
||||
return this.authService.loginUserWechatPhone(
|
||||
dto.phoneCode,
|
||||
clientApp,
|
||||
platform,
|
||||
guestId,
|
||||
dto.loginCode,
|
||||
);
|
||||
}
|
||||
|
||||
@Post('auth/wechat/bind-phone')
|
||||
bindPhoneLegacy(@Req() req: Request, @Body() dto: BindWechatPhoneDto) {
|
||||
return this.authService.bindWechatPhone(
|
||||
|
||||
@@ -751,19 +751,13 @@ export class AuthService {
|
||||
return this.issueToken('PARTNER', account.id, clientApp, false, undefined, undefined, this.partnerTokenPayload(account, primary));
|
||||
}
|
||||
|
||||
async loginUser(phone: string, code: string, clientApp: ClientApp, guestId?: bigint) {
|
||||
const normalizedPhone = this.assertMobilePhone(phone);
|
||||
const existingUser = await this.prisma.user.findUnique({
|
||||
where: { phone: normalizedPhone },
|
||||
select: { id: true },
|
||||
});
|
||||
await this.verifySmsForUser(
|
||||
normalizedPhone,
|
||||
code,
|
||||
SmsScene.USER_LOGIN,
|
||||
clientApp,
|
||||
guestId ?? existingUser?.id,
|
||||
);
|
||||
/** 手机号已验证后建号/登录并签发会话(短信登录与微信手机号快捷登录共用) */
|
||||
private async issueUserSessionByVerifiedPhone(
|
||||
normalizedPhone: string,
|
||||
clientApp: ClientApp,
|
||||
guestId: bigint | undefined,
|
||||
method: 'sms' | 'wechat_phone',
|
||||
) {
|
||||
let user: UserRow | null = await this.prisma.user.findUnique({
|
||||
where: { phone: normalizedPhone },
|
||||
include: { avatar: true },
|
||||
@@ -816,12 +810,12 @@ export class AuthService {
|
||||
if (guestId && guestId !== user.id) {
|
||||
user = await this.mergeUsers(guestId, user.id);
|
||||
this.analyticsService.trackOneSafe(user.id, clientApp, {
|
||||
eventName: 'sms_login',
|
||||
extraJson: { method: 'sms', accountMerged: true },
|
||||
eventName: method === 'sms' ? 'sms_login' : 'wechat_phone_login',
|
||||
extraJson: { method, accountMerged: true },
|
||||
});
|
||||
this.analyticsService.trackOneSafe(user.id, clientApp, {
|
||||
eventName: 'login_success',
|
||||
extraJson: { method: 'sms', accountMerged: true },
|
||||
extraJson: { method, accountMerged: true },
|
||||
});
|
||||
return this.buildSessionResponse(user, clientApp, user.deviceKey, { accountMerged: true });
|
||||
} else {
|
||||
@@ -832,17 +826,70 @@ export class AuthService {
|
||||
if (!user) throw new BadRequestException('登录失败');
|
||||
|
||||
this.analyticsService.trackOneSafe(user.id, clientApp, {
|
||||
eventName: 'sms_login',
|
||||
extraJson: { method: 'sms' },
|
||||
eventName: method === 'sms' ? 'sms_login' : 'wechat_phone_login',
|
||||
extraJson: { method },
|
||||
});
|
||||
this.analyticsService.trackOneSafe(user.id, clientApp, {
|
||||
eventName: 'login_success',
|
||||
extraJson: { method: 'sms' },
|
||||
extraJson: { method },
|
||||
});
|
||||
|
||||
return this.buildSessionResponse(user, clientApp, user.deviceKey);
|
||||
}
|
||||
|
||||
async loginUser(phone: string, code: string, clientApp: ClientApp, guestId?: bigint) {
|
||||
const normalizedPhone = this.assertMobilePhone(phone);
|
||||
const existingUser = await this.prisma.user.findUnique({
|
||||
where: { phone: normalizedPhone },
|
||||
select: { id: true },
|
||||
});
|
||||
await this.verifySmsForUser(
|
||||
normalizedPhone,
|
||||
code,
|
||||
SmsScene.USER_LOGIN,
|
||||
clientApp,
|
||||
guestId ?? existingUser?.id,
|
||||
);
|
||||
return this.issueUserSessionByVerifiedPhone(normalizedPhone, clientApp, guestId, 'sms');
|
||||
}
|
||||
|
||||
/** 小程序 getPhoneNumber:用微信返回的 phoneCode 登录/注册,可选 loginCode 绑定 openId */
|
||||
async loginUserWechatPhone(
|
||||
phoneCode: string,
|
||||
clientApp: ClientApp,
|
||||
platform: 'h5' | 'mini' = 'mini',
|
||||
guestId?: bigint,
|
||||
loginCode?: string,
|
||||
) {
|
||||
this.assertWechatEnabled();
|
||||
if (platform !== 'mini') {
|
||||
throw new BadRequestException('仅小程序支持手机号快捷登录');
|
||||
}
|
||||
const phone = await this.wechatProvider.getPhoneNumberByCode(phoneCode, platform);
|
||||
const normalizedPhone = this.assertMobilePhone(phone);
|
||||
const session = await this.issueUserSessionByVerifiedPhone(
|
||||
normalizedPhone,
|
||||
clientApp,
|
||||
guestId,
|
||||
'wechat_phone',
|
||||
);
|
||||
|
||||
if (loginCode?.trim() && session.actorId) {
|
||||
try {
|
||||
await this.bindUserWechat(
|
||||
BigInt(session.actorId),
|
||||
{ code: loginCode.trim() },
|
||||
clientApp,
|
||||
'mini',
|
||||
);
|
||||
} catch {
|
||||
/* 绑定 openId 失败不阻断已成功的手机号登录 */
|
||||
}
|
||||
}
|
||||
|
||||
return session;
|
||||
}
|
||||
|
||||
async bindPhone(actorId: bigint, phone: string, code: string, clientApp: ClientApp) {
|
||||
const normalizedPhone = this.assertMobilePhone(phone);
|
||||
await this.verifySmsForUser(normalizedPhone, code, SmsScene.BIND_PHONE, clientApp, actorId);
|
||||
|
||||
@@ -55,6 +55,22 @@ export class LoginWechatDto {
|
||||
platform?: 'h5' | 'mini';
|
||||
}
|
||||
|
||||
/** 小程序 getPhoneNumber 返回的 phoneCode,可选附带 wx.login code 绑定 openId */
|
||||
export class LoginWechatPhoneDto {
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
phoneCode: string;
|
||||
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
loginCode?: string;
|
||||
|
||||
@IsString()
|
||||
@IsIn(['h5', 'mini'])
|
||||
@IsOptional()
|
||||
platform?: 'h5' | 'mini';
|
||||
}
|
||||
|
||||
export class BindWechatPhoneDto {
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
|
||||
Reference in New Issue
Block a user