diff --git a/apps/admin-web/src/lib/user-log.ts b/apps/admin-web/src/lib/user-log.ts index 4c3a87a..9114728 100644 --- a/apps/admin-web/src/lib/user-log.ts +++ b/apps/admin-web/src/lib/user-log.ts @@ -9,14 +9,14 @@ export type UserLogCategory = | 'profile'; const USER_LOG_EVENT_CATEGORIES: Record = { - login: ['login_success', 'sms_login'], + login: ['login_success', 'sms_login', 'sms_send', 'sms_verify_fail'], browse_product: ['home_view', 'product_click', 'product_detail_view'], order: ['order_confirm_view', 'order_submit'], pay: ['pay_success', 'pay_fail'], wechat_auth: ['wechat_login', 'wechat_phone', 'wechat_location', 'wechat_album'], browse_store: ['store_list_view', 'store_detail_view'], 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 }> = [ diff --git a/packages/shared-types/src/user-log.ts b/packages/shared-types/src/user-log.ts index 229119e..41daaf8 100644 --- a/packages/shared-types/src/user-log.ts +++ b/packages/shared-types/src/user-log.ts @@ -9,14 +9,14 @@ export type UserLogCategory = | 'profile'; export const USER_LOG_EVENT_CATEGORIES: Record = { - login: ['login_success', 'sms_login'], + login: ['login_success', 'sms_login', 'sms_send', 'sms_verify_fail'], browse_product: ['home_view', 'product_click', 'product_detail_view'], order: ['order_confirm_view', 'order_submit'], pay: ['pay_success', 'pay_fail'], wechat_auth: ['wechat_login', 'wechat_phone', 'wechat_location', 'wechat_album'], browse_store: ['store_list_view', 'store_detail_view'], 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 }> = [ diff --git a/server/dukang-api/src/integrations/sms/sms.aliyun.provider.ts b/server/dukang-api/src/integrations/sms/sms.aliyun.provider.ts index 0715ec2..6e4e25e 100644 --- a/server/dukang-api/src/integrations/sms/sms.aliyun.provider.ts +++ b/server/dukang-api/src/integrations/sms/sms.aliyun.provider.ts @@ -3,7 +3,7 @@ import Dysmsapi20170525, { SendSmsRequest } from '@alicloud/dysmsapi20170525'; import * as OpenApi from '@alicloud/openapi-client'; import { loadAppConfig } from '@dukang/shared-types'; 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'; function serializeSmsResponseBody(body: unknown): Record | undefined { @@ -19,6 +19,10 @@ function serializeSmsResponseBody(body: unknown): Record | undef return Object.keys(out).length ? out : undefined; } +function maskPhone(phone: string) { + return `${phone.slice(0, 3)}****${phone.slice(-4)}`; +} + @Injectable() export class SmsAliyunProvider implements ISmsProvider { private readonly logger = new Logger(SmsAliyunProvider.name); @@ -54,8 +58,9 @@ export class SmsAliyunProvider implements ISmsProvider { return this.client; } - async send(phone: string, scene: string): Promise { + async send(phone: string, scene: string, actorRef?: SmsActorRef): Promise { const code = await this.smsCodeStore.generateAndStore(phone, scene); + const masked = maskPhone(phone); const request = new SendSmsRequest({ phoneNumbers: phone, signName: this.config.aliyunSmsSignName, @@ -63,43 +68,53 @@ export class SmsAliyunProvider implements ISmsProvider { 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 { const response = await this.getClient().sendSms(request); const bizId = response.body?.bizId ?? undefined; const ok = response.body?.code === 'OK'; const responseBody = serializeSmsResponseBody(response.body); - await this.prisma.logThirdParty.create({ + const log = await this.prisma.logThirdParty.create({ data: { - provider: 'SMS', - scene, - requestBody: { phone, templateCode: this.config.aliyunSmsTemplateCode, signName: this.config.aliyunSmsSignName }, + ...baseLog, responseBody, externalNo: bizId, status: ok ? 'SUCCESS' : 'FAILED', errorMessage: ok ? undefined : response.body?.message ?? 'SMS send failed', }, }); - logged = true; - if (!ok) { - throw new Error(response.body?.message ?? '短信发送失败'); + if (ok) { + this.logger.log(`Aliyun SMS sent to ${masked} scene=${scene} bizId=${bizId ?? '-'}`); + } 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) { const message = err instanceof Error ? err.message : String(err); this.logger.error(`Aliyun SMS send failed: ${message}`); - if (!logged) { - await this.prisma.logThirdParty.create({ - data: { - provider: 'SMS', - scene, - requestBody: { phone, templateCode: this.config.aliyunSmsTemplateCode, signName: this.config.aliyunSmsSignName }, - status: 'FAILED', - errorMessage: message.slice(0, 512), - }, - }); - } - throw err; + const log = await this.prisma.logThirdParty.create({ + data: { + ...baseLog, + status: 'FAILED', + errorMessage: message.slice(0, 512), + }, + }); + return { logId: log.id, ok: false, errorMessage: message }; } } diff --git a/server/dukang-api/src/integrations/sms/sms.interface.ts b/server/dukang-api/src/integrations/sms/sms.interface.ts index b1a13da..1db186e 100644 --- a/server/dukang-api/src/integrations/sms/sms.interface.ts +++ b/server/dukang-api/src/integrations/sms/sms.interface.ts @@ -1,4 +1,15 @@ +export type SmsActorRef = { + refType: string; + refId: bigint; +}; + +export type SmsSendResult = { + logId: bigint; + ok: boolean; + errorMessage?: string; +}; + export interface ISmsProvider { - send(phone: string, scene: string): Promise; + send(phone: string, scene: string, actorRef?: SmsActorRef): Promise; verify(phone: string, code: string, scene: string): Promise; } diff --git a/server/dukang-api/src/integrations/sms/sms.mock.provider.ts b/server/dukang-api/src/integrations/sms/sms.mock.provider.ts index e78102c..600babe 100644 --- a/server/dukang-api/src/integrations/sms/sms.mock.provider.ts +++ b/server/dukang-api/src/integrations/sms/sms.mock.provider.ts @@ -1,8 +1,12 @@ import { Injectable, Logger } from '@nestjs/common'; 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'; +function maskPhone(phone: string) { + return `${phone.slice(0, 3)}****${phone.slice(-4)}`; +} + @Injectable() export class SmsMockProvider implements ISmsProvider { private readonly logger = new Logger(SmsMockProvider.name); @@ -12,20 +16,23 @@ export class SmsMockProvider implements ISmsProvider { private readonly prisma: PrismaService, ) {} - async send(phone: string, scene: string): Promise { + async send(phone: string, scene: string, actorRef?: SmsActorRef): Promise { 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}`); - await this.prisma.logThirdParty.create({ + const log = await this.prisma.logThirdParty.create({ data: { provider: 'SMS', scene, + refType: actorRef?.refType, + refId: actorRef?.refId, requestBody: { phone: masked, mode: 'MOCK', scene }, responseBody: { mock: true, hint: 'use MOCK_SMS_CODE or check server log' }, status: 'SUCCESS', }, }); + return { logId: log.id, ok: true }; } async verify(phone: string, code: string, scene: string): Promise { diff --git a/server/dukang-api/src/modules/iam/admin-auth.controller.ts b/server/dukang-api/src/modules/iam/admin-auth.controller.ts index c275795..025144c 100644 --- a/server/dukang-api/src/modules/iam/admin-auth.controller.ts +++ b/server/dukang-api/src/modules/iam/admin-auth.controller.ts @@ -12,7 +12,7 @@ export class AdminAuthController { @Post('sms/send') 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') diff --git a/server/dukang-api/src/modules/iam/auth.controller.ts b/server/dukang-api/src/modules/iam/auth.controller.ts index 008983c..66a7722 100644 --- a/server/dukang-api/src/modules/iam/auth.controller.ts +++ b/server/dukang-api/src/modules/iam/auth.controller.ts @@ -31,8 +31,14 @@ export class UserAuthController { } @Post('auth/sms/send') - sendSms(@Body() dto: SendSmsDto) { - return this.authService.sendSms(dto.phone, dto.scene); + @UseGuards(OptionalJwtAuthGuard) + 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') @@ -85,7 +91,7 @@ export class ShopAuthController { @Post('sms/send') 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') @@ -105,7 +111,7 @@ export class PartnerAuthController { @Post('sms/send') 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') diff --git a/server/dukang-api/src/modules/iam/auth.service.ts b/server/dukang-api/src/modules/iam/auth.service.ts index 316846c..bd98775 100644 --- a/server/dukang-api/src/modules/iam/auth.service.ts +++ b/server/dukang-api/src/modules/iam/auth.service.ts @@ -15,6 +15,7 @@ import { PrismaService } from '../../common/prisma/prisma.module'; import { RedisService } from '../../common/redis/redis.service'; import { SMS_PROVIDER, WECHAT_PROVIDER } from '../../integrations/integrations.constants'; import { ISmsProvider } from '../../integrations/sms/sms.interface'; +import type { SmsActorRef } from '../../integrations/sms/sms.interface'; import { SmsCodeStore } from '../../integrations/sms/sms-code.store'; import type { IWechatProvider } from '../../integrations/wechat/wechat.interface'; import { serializeBigInt } from '../../common/decorators/current-user.decorator'; @@ -69,18 +70,144 @@ export class AuthService { 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 { + 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, + 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); if (!Object.values(SmsScene).includes(scene as SmsScene)) { 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); try { - await this.smsProvider.send(normalizedPhone, scene); + const result = await this.smsProvider.send(normalizedPhone, scene, actorRef); 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) { if (err instanceof BadRequestException) throw err; 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); } return { sent: true }; @@ -138,7 +265,17 @@ export class AuthService { async loginUser(phone: string, code: string, clientApp: ClientApp, guestId?: bigint) { 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({ where: { phone: normalizedPhone }, include: { avatar: true }, @@ -211,7 +348,7 @@ export class AuthService { async bindPhone(actorId: bigint, phone: string, code: string, clientApp: ClientApp) { 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); 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); } @@ -420,12 +561,12 @@ export class AuthService { clientApp: ClientApp, ) { this.assertWechatEnabled(); - await this.smsProvider.verify(phone, code, SmsScene.BIND_PHONE); const wxSession = await this.redis.getJson(`wx:session:${wxSessionKey}`); if (!wxSession) throw new BadRequestException('微信会话已过期,请重新授权'); 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({ where: { wxOpenId: wxSession.openId, status: 1, mergedIntoUserId: null }, include: { avatar: true }, @@ -508,6 +649,10 @@ export class AuthService { if (!targetUserId) throw new BadRequestException('绑定失败'); const user = await this.assertActiveUser(targetUserId); 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, { eventName: 'wechat_phone', extraJson: { method: 'bind_phone' },