短信验证绑定日志内容
This commit is contained in:
@@ -9,14 +9,14 @@ export type UserLogCategory =
|
|||||||
| 'profile';
|
| 'profile';
|
||||||
|
|
||||||
const USER_LOG_EVENT_CATEGORIES: Record<UserLogCategory, readonly string[]> = {
|
const USER_LOG_EVENT_CATEGORIES: Record<UserLogCategory, readonly string[]> = {
|
||||||
login: ['login_success', 'sms_login'],
|
login: ['login_success', 'sms_login', 'sms_send', 'sms_verify_fail'],
|
||||||
browse_product: ['home_view', 'product_click', 'product_detail_view'],
|
browse_product: ['home_view', 'product_click', 'product_detail_view'],
|
||||||
order: ['order_confirm_view', 'order_submit'],
|
order: ['order_confirm_view', 'order_submit'],
|
||||||
pay: ['pay_success', 'pay_fail'],
|
pay: ['pay_success', 'pay_fail'],
|
||||||
wechat_auth: ['wechat_login', 'wechat_phone', 'wechat_location', 'wechat_album'],
|
wechat_auth: ['wechat_login', 'wechat_phone', 'wechat_location', 'wechat_album'],
|
||||||
browse_store: ['store_list_view', 'store_detail_view'],
|
browse_store: ['store_list_view', 'store_detail_view'],
|
||||||
redeem: ['benefit_redeem_start', 'benefit_redeem_success'],
|
redeem: ['benefit_redeem_start', 'benefit_redeem_success'],
|
||||||
profile: ['profile_update'],
|
profile: ['profile_update', 'bind_phone'],
|
||||||
};
|
};
|
||||||
|
|
||||||
export const USER_LOG_CATEGORY_OPTIONS: Array<{ value: UserLogCategory | ''; label: string }> = [
|
export const USER_LOG_CATEGORY_OPTIONS: Array<{ value: UserLogCategory | ''; label: string }> = [
|
||||||
|
|||||||
@@ -9,14 +9,14 @@ export type UserLogCategory =
|
|||||||
| 'profile';
|
| 'profile';
|
||||||
|
|
||||||
export const USER_LOG_EVENT_CATEGORIES: Record<UserLogCategory, readonly string[]> = {
|
export const USER_LOG_EVENT_CATEGORIES: Record<UserLogCategory, readonly string[]> = {
|
||||||
login: ['login_success', 'sms_login'],
|
login: ['login_success', 'sms_login', 'sms_send', 'sms_verify_fail'],
|
||||||
browse_product: ['home_view', 'product_click', 'product_detail_view'],
|
browse_product: ['home_view', 'product_click', 'product_detail_view'],
|
||||||
order: ['order_confirm_view', 'order_submit'],
|
order: ['order_confirm_view', 'order_submit'],
|
||||||
pay: ['pay_success', 'pay_fail'],
|
pay: ['pay_success', 'pay_fail'],
|
||||||
wechat_auth: ['wechat_login', 'wechat_phone', 'wechat_location', 'wechat_album'],
|
wechat_auth: ['wechat_login', 'wechat_phone', 'wechat_location', 'wechat_album'],
|
||||||
browse_store: ['store_list_view', 'store_detail_view'],
|
browse_store: ['store_list_view', 'store_detail_view'],
|
||||||
redeem: ['benefit_redeem_start', 'benefit_redeem_success'],
|
redeem: ['benefit_redeem_start', 'benefit_redeem_success'],
|
||||||
profile: ['profile_update'],
|
profile: ['profile_update', 'bind_phone'],
|
||||||
};
|
};
|
||||||
|
|
||||||
export const USER_LOG_CATEGORY_OPTIONS: Array<{ value: UserLogCategory | ''; label: string }> = [
|
export const USER_LOG_CATEGORY_OPTIONS: Array<{ value: UserLogCategory | ''; label: string }> = [
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import Dysmsapi20170525, { SendSmsRequest } from '@alicloud/dysmsapi20170525';
|
|||||||
import * as OpenApi from '@alicloud/openapi-client';
|
import * as OpenApi from '@alicloud/openapi-client';
|
||||||
import { loadAppConfig } from '@dukang/shared-types';
|
import { loadAppConfig } from '@dukang/shared-types';
|
||||||
import { PrismaService } from '../../common/prisma/prisma.module';
|
import { PrismaService } from '../../common/prisma/prisma.module';
|
||||||
import { ISmsProvider } from './sms.interface';
|
import type { ISmsProvider, SmsActorRef, SmsSendResult } from './sms.interface';
|
||||||
import { SmsCodeStore } from './sms-code.store';
|
import { SmsCodeStore } from './sms-code.store';
|
||||||
|
|
||||||
function serializeSmsResponseBody(body: unknown): Record<string, string> | undefined {
|
function serializeSmsResponseBody(body: unknown): Record<string, string> | undefined {
|
||||||
@@ -19,6 +19,10 @@ function serializeSmsResponseBody(body: unknown): Record<string, string> | undef
|
|||||||
return Object.keys(out).length ? out : undefined;
|
return Object.keys(out).length ? out : undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function maskPhone(phone: string) {
|
||||||
|
return `${phone.slice(0, 3)}****${phone.slice(-4)}`;
|
||||||
|
}
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class SmsAliyunProvider implements ISmsProvider {
|
export class SmsAliyunProvider implements ISmsProvider {
|
||||||
private readonly logger = new Logger(SmsAliyunProvider.name);
|
private readonly logger = new Logger(SmsAliyunProvider.name);
|
||||||
@@ -54,8 +58,9 @@ export class SmsAliyunProvider implements ISmsProvider {
|
|||||||
return this.client;
|
return this.client;
|
||||||
}
|
}
|
||||||
|
|
||||||
async send(phone: string, scene: string): Promise<void> {
|
async send(phone: string, scene: string, actorRef?: SmsActorRef): Promise<SmsSendResult> {
|
||||||
const code = await this.smsCodeStore.generateAndStore(phone, scene);
|
const code = await this.smsCodeStore.generateAndStore(phone, scene);
|
||||||
|
const masked = maskPhone(phone);
|
||||||
const request = new SendSmsRequest({
|
const request = new SendSmsRequest({
|
||||||
phoneNumbers: phone,
|
phoneNumbers: phone,
|
||||||
signName: this.config.aliyunSmsSignName,
|
signName: this.config.aliyunSmsSignName,
|
||||||
@@ -63,43 +68,53 @@ export class SmsAliyunProvider implements ISmsProvider {
|
|||||||
templateParam: JSON.stringify({ code }),
|
templateParam: JSON.stringify({ code }),
|
||||||
});
|
});
|
||||||
|
|
||||||
let logged = false;
|
const baseLog = {
|
||||||
|
provider: 'SMS' as const,
|
||||||
|
scene,
|
||||||
|
refType: actorRef?.refType,
|
||||||
|
refId: actorRef?.refId,
|
||||||
|
requestBody: {
|
||||||
|
phone: masked,
|
||||||
|
templateCode: this.config.aliyunSmsTemplateCode,
|
||||||
|
signName: this.config.aliyunSmsSignName,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await this.getClient().sendSms(request);
|
const response = await this.getClient().sendSms(request);
|
||||||
const bizId = response.body?.bizId ?? undefined;
|
const bizId = response.body?.bizId ?? undefined;
|
||||||
const ok = response.body?.code === 'OK';
|
const ok = response.body?.code === 'OK';
|
||||||
const responseBody = serializeSmsResponseBody(response.body);
|
const responseBody = serializeSmsResponseBody(response.body);
|
||||||
await this.prisma.logThirdParty.create({
|
const log = await this.prisma.logThirdParty.create({
|
||||||
data: {
|
data: {
|
||||||
provider: 'SMS',
|
...baseLog,
|
||||||
scene,
|
|
||||||
requestBody: { phone, templateCode: this.config.aliyunSmsTemplateCode, signName: this.config.aliyunSmsSignName },
|
|
||||||
responseBody,
|
responseBody,
|
||||||
externalNo: bizId,
|
externalNo: bizId,
|
||||||
status: ok ? 'SUCCESS' : 'FAILED',
|
status: ok ? 'SUCCESS' : 'FAILED',
|
||||||
errorMessage: ok ? undefined : response.body?.message ?? 'SMS send failed',
|
errorMessage: ok ? undefined : response.body?.message ?? 'SMS send failed',
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
logged = true;
|
if (ok) {
|
||||||
if (!ok) {
|
this.logger.log(`Aliyun SMS sent to ${masked} scene=${scene} bizId=${bizId ?? '-'}`);
|
||||||
throw new Error(response.body?.message ?? '短信发送失败');
|
} else {
|
||||||
|
this.logger.warn(`Aliyun SMS failed for ${masked} scene=${scene}: ${response.body?.message}`);
|
||||||
}
|
}
|
||||||
this.logger.log(`Aliyun SMS sent to ${phone.slice(0, 3)}****${phone.slice(-4)} scene=${scene} bizId=${bizId ?? '-'}`);
|
return {
|
||||||
|
logId: log.id,
|
||||||
|
ok,
|
||||||
|
errorMessage: ok ? undefined : response.body?.message ?? '短信发送失败',
|
||||||
|
};
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const message = err instanceof Error ? err.message : String(err);
|
const message = err instanceof Error ? err.message : String(err);
|
||||||
this.logger.error(`Aliyun SMS send failed: ${message}`);
|
this.logger.error(`Aliyun SMS send failed: ${message}`);
|
||||||
if (!logged) {
|
const log = await this.prisma.logThirdParty.create({
|
||||||
await this.prisma.logThirdParty.create({
|
data: {
|
||||||
data: {
|
...baseLog,
|
||||||
provider: 'SMS',
|
status: 'FAILED',
|
||||||
scene,
|
errorMessage: message.slice(0, 512),
|
||||||
requestBody: { phone, templateCode: this.config.aliyunSmsTemplateCode, signName: this.config.aliyunSmsSignName },
|
},
|
||||||
status: 'FAILED',
|
});
|
||||||
errorMessage: message.slice(0, 512),
|
return { logId: log.id, ok: false, errorMessage: message };
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
throw err;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,15 @@
|
|||||||
|
export type SmsActorRef = {
|
||||||
|
refType: string;
|
||||||
|
refId: bigint;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type SmsSendResult = {
|
||||||
|
logId: bigint;
|
||||||
|
ok: boolean;
|
||||||
|
errorMessage?: string;
|
||||||
|
};
|
||||||
|
|
||||||
export interface ISmsProvider {
|
export interface ISmsProvider {
|
||||||
send(phone: string, scene: string): Promise<void>;
|
send(phone: string, scene: string, actorRef?: SmsActorRef): Promise<SmsSendResult>;
|
||||||
verify(phone: string, code: string, scene: string): Promise<void>;
|
verify(phone: string, code: string, scene: string): Promise<void>;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,12 @@
|
|||||||
import { Injectable, Logger } from '@nestjs/common';
|
import { Injectable, Logger } from '@nestjs/common';
|
||||||
import { PrismaService } from '../../common/prisma/prisma.module';
|
import { PrismaService } from '../../common/prisma/prisma.module';
|
||||||
import { ISmsProvider } from './sms.interface';
|
import type { ISmsProvider, SmsActorRef, SmsSendResult } from './sms.interface';
|
||||||
import { SmsCodeStore } from './sms-code.store';
|
import { SmsCodeStore } from './sms-code.store';
|
||||||
|
|
||||||
|
function maskPhone(phone: string) {
|
||||||
|
return `${phone.slice(0, 3)}****${phone.slice(-4)}`;
|
||||||
|
}
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class SmsMockProvider implements ISmsProvider {
|
export class SmsMockProvider implements ISmsProvider {
|
||||||
private readonly logger = new Logger(SmsMockProvider.name);
|
private readonly logger = new Logger(SmsMockProvider.name);
|
||||||
@@ -12,20 +16,23 @@ export class SmsMockProvider implements ISmsProvider {
|
|||||||
private readonly prisma: PrismaService,
|
private readonly prisma: PrismaService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async send(phone: string, scene: string): Promise<void> {
|
async send(phone: string, scene: string, actorRef?: SmsActorRef): Promise<SmsSendResult> {
|
||||||
const code = await this.smsCodeStore.generateAndStore(phone, scene);
|
const code = await this.smsCodeStore.generateAndStore(phone, scene);
|
||||||
const masked = `${phone.slice(0, 3)}****${phone.slice(-4)}`;
|
const masked = maskPhone(phone);
|
||||||
this.logger.log(`Mock SMS → ${masked} scene=${scene} code=${code}`);
|
this.logger.log(`Mock SMS → ${masked} scene=${scene} code=${code}`);
|
||||||
|
|
||||||
await this.prisma.logThirdParty.create({
|
const log = await this.prisma.logThirdParty.create({
|
||||||
data: {
|
data: {
|
||||||
provider: 'SMS',
|
provider: 'SMS',
|
||||||
scene,
|
scene,
|
||||||
|
refType: actorRef?.refType,
|
||||||
|
refId: actorRef?.refId,
|
||||||
requestBody: { phone: masked, mode: 'MOCK', scene },
|
requestBody: { phone: masked, mode: 'MOCK', scene },
|
||||||
responseBody: { mock: true, hint: 'use MOCK_SMS_CODE or check server log' },
|
responseBody: { mock: true, hint: 'use MOCK_SMS_CODE or check server log' },
|
||||||
status: 'SUCCESS',
|
status: 'SUCCESS',
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
return { logId: log.id, ok: true };
|
||||||
}
|
}
|
||||||
|
|
||||||
async verify(phone: string, code: string, scene: string): Promise<void> {
|
async verify(phone: string, code: string, scene: string): Promise<void> {
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ export class AdminAuthController {
|
|||||||
|
|
||||||
@Post('sms/send')
|
@Post('sms/send')
|
||||||
sendSms(@Body() dto: SendSmsDto) {
|
sendSms(@Body() dto: SendSmsDto) {
|
||||||
return this.authService.sendSms(dto.phone, dto.scene);
|
return this.authService.sendSms(dto.phone, dto.scene, { clientApp: ClientApp.HQ_WEB });
|
||||||
}
|
}
|
||||||
|
|
||||||
@Post('login/sms')
|
@Post('login/sms')
|
||||||
|
|||||||
@@ -31,8 +31,14 @@ export class UserAuthController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Post('auth/sms/send')
|
@Post('auth/sms/send')
|
||||||
sendSms(@Body() dto: SendSmsDto) {
|
@UseGuards(OptionalJwtAuthGuard)
|
||||||
return this.authService.sendSms(dto.phone, dto.scene);
|
sendSms(@Req() req: Request, @Body() dto: SendSmsDto) {
|
||||||
|
const guest = (req as Request & { user?: AuthUser }).user;
|
||||||
|
const guestId = guest?.actorType === 'USER' ? guest.actorId : undefined;
|
||||||
|
return this.authService.sendSms(dto.phone, dto.scene, {
|
||||||
|
guestUserId: guestId,
|
||||||
|
clientApp: ClientApp.USER_H5,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@Post('auth/login/sms')
|
@Post('auth/login/sms')
|
||||||
@@ -85,7 +91,7 @@ export class ShopAuthController {
|
|||||||
|
|
||||||
@Post('sms/send')
|
@Post('sms/send')
|
||||||
sendSms(@Body() dto: SendSmsDto) {
|
sendSms(@Body() dto: SendSmsDto) {
|
||||||
return this.authService.sendSms(dto.phone, dto.scene);
|
return this.authService.sendSms(dto.phone, dto.scene, { clientApp: ClientApp.SHOP_H5 });
|
||||||
}
|
}
|
||||||
|
|
||||||
@Post('login/sms')
|
@Post('login/sms')
|
||||||
@@ -105,7 +111,7 @@ export class PartnerAuthController {
|
|||||||
|
|
||||||
@Post('sms/send')
|
@Post('sms/send')
|
||||||
sendSms(@Body() dto: SendSmsDto) {
|
sendSms(@Body() dto: SendSmsDto) {
|
||||||
return this.authService.sendSms(dto.phone, dto.scene);
|
return this.authService.sendSms(dto.phone, dto.scene, { clientApp: ClientApp.PARTNER_H5 });
|
||||||
}
|
}
|
||||||
|
|
||||||
@Post('login/sms')
|
@Post('login/sms')
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import { PrismaService } from '../../common/prisma/prisma.module';
|
|||||||
import { RedisService } from '../../common/redis/redis.service';
|
import { RedisService } from '../../common/redis/redis.service';
|
||||||
import { SMS_PROVIDER, WECHAT_PROVIDER } from '../../integrations/integrations.constants';
|
import { SMS_PROVIDER, WECHAT_PROVIDER } from '../../integrations/integrations.constants';
|
||||||
import { ISmsProvider } from '../../integrations/sms/sms.interface';
|
import { ISmsProvider } from '../../integrations/sms/sms.interface';
|
||||||
|
import type { SmsActorRef } from '../../integrations/sms/sms.interface';
|
||||||
import { SmsCodeStore } from '../../integrations/sms/sms-code.store';
|
import { SmsCodeStore } from '../../integrations/sms/sms-code.store';
|
||||||
import type { IWechatProvider } from '../../integrations/wechat/wechat.interface';
|
import type { IWechatProvider } from '../../integrations/wechat/wechat.interface';
|
||||||
import { serializeBigInt } from '../../common/decorators/current-user.decorator';
|
import { serializeBigInt } from '../../common/decorators/current-user.decorator';
|
||||||
@@ -69,18 +70,144 @@ export class AuthService {
|
|||||||
return trimmed;
|
return trimmed;
|
||||||
}
|
}
|
||||||
|
|
||||||
async sendSms(phone: string, scene: string) {
|
private maskPhone(phone: string) {
|
||||||
|
return `${phone.slice(0, 3)}****${phone.slice(-4)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
private clientAppForScene(scene: string): ClientApp {
|
||||||
|
switch (scene) {
|
||||||
|
case SmsScene.STORE_LOGIN:
|
||||||
|
return ClientApp.SHOP_H5;
|
||||||
|
case SmsScene.PARTNER_LOGIN:
|
||||||
|
case SmsScene.PARTNER_STAFF_ADD:
|
||||||
|
return ClientApp.PARTNER_H5;
|
||||||
|
case SmsScene.HQ_LOGIN:
|
||||||
|
return ClientApp.HQ_WEB;
|
||||||
|
default:
|
||||||
|
return ClientApp.USER_H5;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async resolveSmsActorRef(
|
||||||
|
phone: string,
|
||||||
|
scene: string,
|
||||||
|
guestUserId?: bigint,
|
||||||
|
): Promise<SmsActorRef | undefined> {
|
||||||
|
switch (scene) {
|
||||||
|
case SmsScene.USER_LOGIN:
|
||||||
|
case SmsScene.BIND_PHONE: {
|
||||||
|
if (guestUserId) return { refType: 'USER', refId: guestUserId };
|
||||||
|
const user = await this.prisma.user.findUnique({
|
||||||
|
where: { phone },
|
||||||
|
select: { id: true },
|
||||||
|
});
|
||||||
|
return user ? { refType: 'USER', refId: user.id } : undefined;
|
||||||
|
}
|
||||||
|
case SmsScene.STORE_LOGIN: {
|
||||||
|
const account = await this.prisma.storeAccount.findUnique({
|
||||||
|
where: { phone },
|
||||||
|
select: { id: true },
|
||||||
|
});
|
||||||
|
return account ? { refType: 'STORE', refId: account.id } : undefined;
|
||||||
|
}
|
||||||
|
case SmsScene.PARTNER_LOGIN:
|
||||||
|
case SmsScene.PARTNER_STAFF_ADD: {
|
||||||
|
const account = await this.prisma.partnerAccount.findUnique({
|
||||||
|
where: { phone },
|
||||||
|
select: { id: true },
|
||||||
|
});
|
||||||
|
return account ? { refType: 'PARTNER', refId: account.id } : undefined;
|
||||||
|
}
|
||||||
|
case SmsScene.HQ_LOGIN: {
|
||||||
|
const account = await this.prisma.hqAccount.findUnique({
|
||||||
|
where: { phone },
|
||||||
|
select: { id: true },
|
||||||
|
});
|
||||||
|
return account ? { refType: 'HQ', refId: account.id } : undefined;
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private trackSmsUserEvent(
|
||||||
|
userId: bigint | undefined,
|
||||||
|
clientApp: ClientApp | string,
|
||||||
|
eventName: string,
|
||||||
|
extraJson: Record<string, unknown>,
|
||||||
|
thirdPartyLogId?: bigint,
|
||||||
|
) {
|
||||||
|
if (!userId) return;
|
||||||
|
this.analyticsService.trackOneSafe(userId, clientApp, {
|
||||||
|
eventName,
|
||||||
|
refType: thirdPartyLogId ? 'THIRD_PARTY_LOG' : undefined,
|
||||||
|
refId: thirdPartyLogId,
|
||||||
|
extraJson,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private async verifySmsForUser(
|
||||||
|
phone: string,
|
||||||
|
code: string,
|
||||||
|
scene: SmsScene.USER_LOGIN | SmsScene.BIND_PHONE,
|
||||||
|
clientApp: ClientApp,
|
||||||
|
userId?: bigint,
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
await this.smsProvider.verify(phone, code, scene);
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof BadRequestException) {
|
||||||
|
this.trackSmsUserEvent(userId, clientApp, 'sms_verify_fail', {
|
||||||
|
scene,
|
||||||
|
phone: this.maskPhone(phone),
|
||||||
|
reason: err.message,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async sendSms(
|
||||||
|
phone: string,
|
||||||
|
scene: string,
|
||||||
|
opts?: { guestUserId?: bigint; clientApp?: ClientApp },
|
||||||
|
) {
|
||||||
const normalizedPhone = this.assertMobilePhone(phone);
|
const normalizedPhone = this.assertMobilePhone(phone);
|
||||||
if (!Object.values(SmsScene).includes(scene as SmsScene)) {
|
if (!Object.values(SmsScene).includes(scene as SmsScene)) {
|
||||||
throw new BadRequestException('无效的验证码场景');
|
throw new BadRequestException('无效的验证码场景');
|
||||||
}
|
}
|
||||||
|
const clientApp = opts?.clientApp ?? this.clientAppForScene(scene);
|
||||||
|
const actorRef = await this.resolveSmsActorRef(normalizedPhone, scene, opts?.guestUserId);
|
||||||
|
const userId = actorRef?.refType === 'USER' ? actorRef.refId : opts?.guestUserId;
|
||||||
|
|
||||||
await this.smsCodeStore.assertSendCooldown(normalizedPhone);
|
await this.smsCodeStore.assertSendCooldown(normalizedPhone);
|
||||||
try {
|
try {
|
||||||
await this.smsProvider.send(normalizedPhone, scene);
|
const result = await this.smsProvider.send(normalizedPhone, scene, actorRef);
|
||||||
await this.smsCodeStore.setSendCooldown(normalizedPhone);
|
await this.smsCodeStore.setSendCooldown(normalizedPhone);
|
||||||
|
this.trackSmsUserEvent(
|
||||||
|
userId,
|
||||||
|
clientApp,
|
||||||
|
'sms_send',
|
||||||
|
{
|
||||||
|
scene,
|
||||||
|
phone: this.maskPhone(normalizedPhone),
|
||||||
|
status: result.ok ? 'success' : 'failed',
|
||||||
|
...(result.errorMessage ? { message: result.errorMessage } : {}),
|
||||||
|
},
|
||||||
|
result.logId,
|
||||||
|
);
|
||||||
|
if (!result.ok) {
|
||||||
|
throw new BadRequestException(result.errorMessage ?? '短信发送失败');
|
||||||
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (err instanceof BadRequestException) throw err;
|
if (err instanceof BadRequestException) throw err;
|
||||||
const message = err instanceof Error ? err.message : '短信发送失败';
|
const message = err instanceof Error ? err.message : '短信发送失败';
|
||||||
|
this.trackSmsUserEvent(userId, clientApp, 'sms_send', {
|
||||||
|
scene,
|
||||||
|
phone: this.maskPhone(normalizedPhone),
|
||||||
|
status: 'failed',
|
||||||
|
message,
|
||||||
|
});
|
||||||
throw new BadRequestException(message);
|
throw new BadRequestException(message);
|
||||||
}
|
}
|
||||||
return { sent: true };
|
return { sent: true };
|
||||||
@@ -138,7 +265,17 @@ export class AuthService {
|
|||||||
|
|
||||||
async loginUser(phone: string, code: string, clientApp: ClientApp, guestId?: bigint) {
|
async loginUser(phone: string, code: string, clientApp: ClientApp, guestId?: bigint) {
|
||||||
const normalizedPhone = this.assertMobilePhone(phone);
|
const normalizedPhone = this.assertMobilePhone(phone);
|
||||||
await this.smsProvider.verify(normalizedPhone, code, SmsScene.USER_LOGIN);
|
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,
|
||||||
|
);
|
||||||
let user: UserRow | null = await this.prisma.user.findUnique({
|
let user: UserRow | null = await this.prisma.user.findUnique({
|
||||||
where: { phone: normalizedPhone },
|
where: { phone: normalizedPhone },
|
||||||
include: { avatar: true },
|
include: { avatar: true },
|
||||||
@@ -211,7 +348,7 @@ export class AuthService {
|
|||||||
|
|
||||||
async bindPhone(actorId: bigint, phone: string, code: string, clientApp: ClientApp) {
|
async bindPhone(actorId: bigint, phone: string, code: string, clientApp: ClientApp) {
|
||||||
const normalizedPhone = this.assertMobilePhone(phone);
|
const normalizedPhone = this.assertMobilePhone(phone);
|
||||||
await this.smsProvider.verify(normalizedPhone, code, SmsScene.BIND_PHONE);
|
await this.verifySmsForUser(normalizedPhone, code, SmsScene.BIND_PHONE, clientApp, actorId);
|
||||||
const guest = await this.assertActiveUser(actorId);
|
const guest = await this.assertActiveUser(actorId);
|
||||||
|
|
||||||
if (guest.phone && guest.phoneVerifiedAt) {
|
if (guest.phone && guest.phoneVerifiedAt) {
|
||||||
@@ -243,6 +380,10 @@ export class AuthService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
this.trackSmsUserEvent(targetUser.id, clientApp, 'bind_phone', {
|
||||||
|
phone: this.maskPhone(normalizedPhone),
|
||||||
|
});
|
||||||
|
|
||||||
return this.buildSessionResponse(targetUser, clientApp, targetUser.deviceKey);
|
return this.buildSessionResponse(targetUser, clientApp, targetUser.deviceKey);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -420,12 +561,12 @@ export class AuthService {
|
|||||||
clientApp: ClientApp,
|
clientApp: ClientApp,
|
||||||
) {
|
) {
|
||||||
this.assertWechatEnabled();
|
this.assertWechatEnabled();
|
||||||
await this.smsProvider.verify(phone, code, SmsScene.BIND_PHONE);
|
|
||||||
|
|
||||||
const wxSession = await this.redis.getJson<WxSessionPayload>(`wx:session:${wxSessionKey}`);
|
const wxSession = await this.redis.getJson<WxSessionPayload>(`wx:session:${wxSessionKey}`);
|
||||||
if (!wxSession) throw new BadRequestException('微信会话已过期,请重新授权');
|
if (!wxSession) throw new BadRequestException('微信会话已过期,请重新授权');
|
||||||
|
|
||||||
const guestId = wxSession.guestId ? BigInt(wxSession.guestId) : undefined;
|
const guestId = wxSession.guestId ? BigInt(wxSession.guestId) : undefined;
|
||||||
|
await this.verifySmsForUser(phone, code, SmsScene.BIND_PHONE, clientApp, guestId);
|
||||||
const wxUser = await this.prisma.user.findFirst({
|
const wxUser = await this.prisma.user.findFirst({
|
||||||
where: { wxOpenId: wxSession.openId, status: 1, mergedIntoUserId: null },
|
where: { wxOpenId: wxSession.openId, status: 1, mergedIntoUserId: null },
|
||||||
include: { avatar: true },
|
include: { avatar: true },
|
||||||
@@ -508,6 +649,10 @@ export class AuthService {
|
|||||||
if (!targetUserId) throw new BadRequestException('绑定失败');
|
if (!targetUserId) throw new BadRequestException('绑定失败');
|
||||||
const user = await this.assertActiveUser(targetUserId);
|
const user = await this.assertActiveUser(targetUserId);
|
||||||
await this.redis.del(`wx:session:${wxSessionKey}`);
|
await this.redis.del(`wx:session:${wxSessionKey}`);
|
||||||
|
this.trackSmsUserEvent(user.id, clientApp, 'bind_phone', {
|
||||||
|
phone: this.maskPhone(phone),
|
||||||
|
method: 'wechat',
|
||||||
|
});
|
||||||
this.analyticsService.trackOneSafe(user.id, clientApp, {
|
this.analyticsService.trackOneSafe(user.id, clientApp, {
|
||||||
eventName: 'wechat_phone',
|
eventName: 'wechat_phone',
|
||||||
extraJson: { method: 'bind_phone' },
|
extraJson: { method: 'bind_phone' },
|
||||||
|
|||||||
Reference in New Issue
Block a user