短信验证调试成功

This commit is contained in:
2026-07-06 13:18:56 +08:00
parent 5ba69eb935
commit 1c978b8adc
62 changed files with 2491 additions and 354 deletions
@@ -1,11 +1,12 @@
import { Module } from '@nestjs/common';
import { Module, forwardRef } from '@nestjs/common';
import { IamModule } from '../iam/iam.module';
import { AnalyticsController } from './analytics.controller';
import { AnalyticsService } from './analytics.service';
@Module({
imports: [IamModule],
imports: [forwardRef(() => IamModule)],
controllers: [AnalyticsController],
providers: [AnalyticsService],
exports: [AnalyticsService],
})
export class AnalyticsModule {}
@@ -2,6 +2,14 @@ import { Injectable } from '@nestjs/common';
import type { ClientApp } from '@prisma/client';
import { PrismaService } from '../../common/prisma/prisma.module';
export type TrackEventInput = {
eventName: string;
pagePath?: string;
refType?: string;
refId?: bigint;
extraJson?: Record<string, unknown>;
};
@Injectable()
export class AnalyticsService {
constructor(private readonly prisma: PrismaService) {}
@@ -13,13 +21,36 @@ export class AnalyticsService {
) {
if (!events?.length) return { count: 0 };
await this.prisma.logUserAnalytics.createMany({
data: events.map((e) => ({
userId,
data: events.map((e) => this.toRow(userId, clientApp, {
eventName: e.eventName,
extraJson: e.params as never,
clientApp: clientApp as ClientApp,
extraJson: e.params,
refType: typeof e.params?.refType === 'string' ? e.params.refType : undefined,
refId: e.params?.refId != null ? BigInt(String(e.params.refId)) : undefined,
pagePath: typeof e.params?.pagePath === 'string' ? e.params.pagePath : undefined,
})),
});
return { count: events.length };
}
async trackOne(userId: bigint, clientApp: ClientApp | string, event: TrackEventInput) {
await this.prisma.logUserAnalytics.create({
data: this.toRow(userId, clientApp, event),
});
}
trackOneSafe(userId: bigint, clientApp: ClientApp | string, event: TrackEventInput) {
void this.trackOne(userId, clientApp, event).catch(() => {});
}
private toRow(userId: bigint, clientApp: ClientApp | string, event: TrackEventInput) {
return {
userId,
eventName: event.eventName,
clientApp: clientApp as ClientApp,
pagePath: event.pagePath,
refType: event.refType,
refId: event.refId,
extraJson: event.extraJson as never,
};
}
}
@@ -9,6 +9,7 @@ export class ClientConfigController {
return {
mockPay: cfg.mockPay,
wechatPayEnabled: cfg.wechatPayEnabled,
mockSms: cfg.mockSms,
};
}
}
@@ -15,8 +15,11 @@ 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 { SmsCodeStore } from '../../integrations/sms/sms-code.store';
import type { IWechatProvider } from '../../integrations/wechat/wechat.interface';
import { serializeBigInt } from '../../common/decorators/current-user.decorator';
import { AnalyticsService } from '../analytics/analytics.service';
import { UserAddressService } from './user-address.service';
import type { User } from '@prisma/client';
@@ -53,10 +56,33 @@ export class AuthService {
private readonly redis: RedisService,
@Inject(SMS_PROVIDER) private readonly smsProvider: ISmsProvider,
@Inject(WECHAT_PROVIDER) private readonly wechatProvider: IWechatProvider,
private readonly analyticsService: AnalyticsService,
private readonly smsCodeStore: SmsCodeStore,
private readonly userAddressService: UserAddressService,
) {}
private assertMobilePhone(phone: string) {
const trimmed = phone.trim();
if (!/^1[3-9]\d{9}$/.test(trimmed)) {
throw new BadRequestException('请输入正确的手机号码');
}
return trimmed;
}
async sendSms(phone: string, scene: string) {
await this.smsProvider.send(phone, scene);
const normalizedPhone = this.assertMobilePhone(phone);
if (!Object.values(SmsScene).includes(scene as SmsScene)) {
throw new BadRequestException('无效的验证码场景');
}
await this.smsCodeStore.assertSendCooldown(normalizedPhone);
try {
await this.smsProvider.send(normalizedPhone, scene);
await this.smsCodeStore.setSendCooldown(normalizedPhone);
} catch (err) {
if (err instanceof BadRequestException) throw err;
const message = err instanceof Error ? err.message : '短信发送失败';
throw new BadRequestException(message);
}
return { sent: true };
}
@@ -111,9 +137,10 @@ export class AuthService {
}
async loginUser(phone: string, code: string, clientApp: ClientApp, guestId?: bigint) {
await this.smsProvider.verify(phone, code, SmsScene.USER_LOGIN);
const normalizedPhone = this.assertMobilePhone(phone);
await this.smsProvider.verify(normalizedPhone, code, SmsScene.USER_LOGIN);
let user: UserRow | null = await this.prisma.user.findUnique({
where: { phone },
where: { phone: normalizedPhone },
include: { avatar: true },
});
@@ -125,9 +152,9 @@ export class AuthService {
user = await this.prisma.user.update({
where: { id: guestId },
data: {
phone,
phone: normalizedPhone,
phoneVerifiedAt: new Date(),
nickname: guest.nickname === '访客' ? `用户${phone.slice(-4)}` : guest.nickname,
nickname: guest.nickname === '访客' ? `用户${normalizedPhone.slice(-4)}` : guest.nickname,
},
include: { avatar: true },
});
@@ -139,10 +166,10 @@ export class AuthService {
if (!user) {
user = await this.prisma.user.create({
data: {
phone,
phone: normalizedPhone,
phoneVerifiedAt: new Date(),
userNo: generateUserNo(),
nickname: `用户${phone.slice(-4)}`,
nickname: `用户${normalizedPhone.slice(-4)}`,
cityPreference: {
create: {
selectedCityCode: '410100',
@@ -170,30 +197,40 @@ export class AuthService {
if (!user) throw new BadRequestException('登录失败');
this.analyticsService.trackOneSafe(user.id, clientApp, {
eventName: 'sms_login',
extraJson: { method: 'sms' },
});
this.analyticsService.trackOneSafe(user.id, clientApp, {
eventName: 'login_success',
extraJson: { method: 'sms' },
});
return this.buildSessionResponse(user, clientApp, user.deviceKey);
}
async bindPhone(actorId: bigint, phone: string, code: string, clientApp: ClientApp) {
await this.smsProvider.verify(phone, code, SmsScene.BIND_PHONE);
const normalizedPhone = this.assertMobilePhone(phone);
await this.smsProvider.verify(normalizedPhone, code, SmsScene.BIND_PHONE);
const guest = await this.assertActiveUser(actorId);
if (guest.phone && guest.phoneVerifiedAt) {
if (guest.phone === phone) {
if (guest.phone === normalizedPhone) {
return this.buildSessionResponse(guest, clientApp, guest.deviceKey);
}
throw new BadRequestException('当前账号已绑定其他手机号');
}
const existing = await this.prisma.user.findUnique({ where: { phone } });
const existing = await this.prisma.user.findUnique({ where: { phone: normalizedPhone } });
let targetUser: UserRow;
if (!existing) {
targetUser = await this.prisma.user.update({
where: { id: guest.id },
data: {
phone,
phone: normalizedPhone,
phoneVerifiedAt: new Date(),
nickname: guest.nickname === '访客' ? `用户${phone.slice(-4)}` : guest.nickname,
nickname: guest.nickname === '访客' ? `用户${normalizedPhone.slice(-4)}` : guest.nickname,
},
include: { avatar: true },
});
@@ -210,9 +247,10 @@ export class AuthService {
}
async loginStore(phone: string, code: string, clientApp: ClientApp) {
await this.smsProvider.verify(phone, code, SmsScene.STORE_LOGIN);
const normalizedPhone = this.assertMobilePhone(phone);
await this.smsProvider.verify(normalizedPhone, code, SmsScene.STORE_LOGIN);
const account = await this.prisma.storeAccount.findUnique({
where: { phone },
where: { phone: normalizedPhone },
include: { store: true },
});
if (!account) throw new BadRequestException('门店账号不存在');
@@ -230,9 +268,10 @@ export class AuthService {
}
async loginPartner(phone: string, code: string, clientApp: ClientApp) {
await this.smsProvider.verify(phone, code, SmsScene.PARTNER_LOGIN);
const normalizedPhone = this.assertMobilePhone(phone);
await this.smsProvider.verify(normalizedPhone, code, SmsScene.PARTNER_LOGIN);
const account = await this.prisma.partnerAccount.findUnique({
where: { phone },
where: { phone: normalizedPhone },
include: { partner: true },
});
if (!account) throw new BadRequestException('合伙人账号不存在');
@@ -251,8 +290,9 @@ export class AuthService {
}
async loginHq(phone: string, code: string, clientApp: ClientApp) {
await this.smsProvider.verify(phone, code, SmsScene.HQ_LOGIN);
const account = await this.prisma.hqAccount.findUnique({ where: { phone } });
const normalizedPhone = this.assertMobilePhone(phone);
await this.smsProvider.verify(normalizedPhone, code, SmsScene.HQ_LOGIN);
const account = await this.prisma.hqAccount.findUnique({ where: { phone: normalizedPhone } });
if (!account) throw new BadRequestException('HQ账号不存在');
if (account.status !== 'ACTIVE') throw new BadRequestException('账号已停用');
await this.prisma.hqAccount.update({
@@ -331,6 +371,14 @@ export class AuthService {
},
include: { avatar: true },
});
this.analyticsService.trackOneSafe(activeUser.id, clientApp, {
eventName: 'wechat_login',
extraJson: { platform },
});
this.analyticsService.trackOneSafe(activeUser.id, clientApp, {
eventName: 'login_success',
extraJson: { method: 'wechat', platform },
});
return this.buildSessionResponse(activeUser, clientApp, activeUser.deviceKey);
}
@@ -460,6 +508,14 @@ export class AuthService {
if (!targetUserId) throw new BadRequestException('绑定失败');
const user = await this.assertActiveUser(targetUserId);
await this.redis.del(`wx:session:${wxSessionKey}`);
this.analyticsService.trackOneSafe(user.id, clientApp, {
eventName: 'wechat_phone',
extraJson: { method: 'bind_phone' },
});
this.analyticsService.trackOneSafe(user.id, clientApp, {
eventName: 'login_success',
extraJson: { method: 'wechat_bind' },
});
return this.buildSessionResponse(user, clientApp, user.deviceKey);
}
@@ -646,6 +702,7 @@ export class AuthService {
});
});
await this.userAddressService.normalizeDefaultAddress(primaryId);
return this.assertActiveUser(primaryId);
}
@@ -1,4 +1,5 @@
import { IsIn, IsNotEmpty, IsOptional, IsString } from 'class-validator';
import { SmsScene } from '@dukang/shared-types';
export class SendSmsDto {
@IsString()
@@ -7,6 +8,7 @@ export class SendSmsDto {
@IsString()
@IsNotEmpty()
@IsIn(Object.values(SmsScene))
scene: string;
}
@@ -1,6 +1,7 @@
import { Module } from '@nestjs/common';
import { Module, forwardRef } from '@nestjs/common';
import { JwtModule } from '@nestjs/jwt';
import { IntegrationsModule } from '../../integrations/integrations.module';
import { AnalyticsModule } from '../analytics/analytics.module';
import { AuthService } from './auth.service';
import {
PartnerAuthController,
@@ -19,6 +20,7 @@ import { HqAuthGuard } from '../../common/guards/hq-auth.guard';
@Module({
imports: [
IntegrationsModule,
forwardRef(() => AnalyticsModule),
JwtModule.register({
secret: process.env.JWT_SECRET || 'dukang-prev1-dev-secret',
signOptions: { expiresIn: process.env.JWT_EXPIRES_IN || '7d' },
@@ -33,6 +35,6 @@ import { HqAuthGuard } from '../../common/guards/hq-auth.guard';
AdminAuthController,
],
providers: [AuthService, UserAddressService, JwtAuthGuard, PhoneVerifiedGuard, OptionalJwtAuthGuard, HqAuthGuard],
exports: [AuthService, JwtModule, JwtAuthGuard, PhoneVerifiedGuard, OptionalJwtAuthGuard, HqAuthGuard],
exports: [AuthService, UserAddressService, JwtModule, JwtAuthGuard, PhoneVerifiedGuard, OptionalJwtAuthGuard, HqAuthGuard],
})
export class IamModule {}
@@ -14,22 +14,37 @@ export class UserAddressService {
return serializeBigInt(list);
}
async normalizeDefaultAddress(userId: bigint) {
const defaults = await this.prisma.userAddress.findMany({
where: { userId, isDefault: 1 },
orderBy: { updatedAt: 'desc' },
});
if (defaults.length <= 1) return;
const keep = defaults[0];
await this.prisma.$transaction(async (tx) => {
await tx.userAddress.updateMany({ where: { userId }, data: { isDefault: 0 } });
await tx.userAddress.update({ where: { id: keep.id }, data: { isDefault: 1 } });
});
}
async create(userId: bigint, body: Record<string, unknown>) {
const isDefault = body.isDefault ? 1 : 0;
if (isDefault) {
await this.prisma.userAddress.updateMany({ where: { userId }, data: { isDefault: 0 } });
}
const address = await this.prisma.userAddress.create({
data: {
userId,
receiverName: String(body.receiverName),
phone: String(body.phone),
province: String(body.province),
city: String(body.city),
district: String(body.district),
detail: String(body.detail),
isDefault,
},
const address = await this.prisma.$transaction(async (tx) => {
if (isDefault) {
await tx.userAddress.updateMany({ where: { userId }, data: { isDefault: 0 } });
}
return tx.userAddress.create({
data: {
userId,
receiverName: String(body.receiverName),
phone: String(body.phone),
province: String(body.province),
city: String(body.city),
district: String(body.district),
detail: String(body.detail),
isDefault,
},
});
});
return serializeBigInt(address);
}
@@ -37,20 +52,22 @@ export class UserAddressService {
async update(userId: bigint, id: bigint, body: Record<string, unknown>) {
const existing = await this.prisma.userAddress.findFirst({ where: { id, userId } });
if (!existing) throw new NotFoundException('地址不存在');
if (body.isDefault) {
await this.prisma.userAddress.updateMany({ where: { userId }, data: { isDefault: 0 } });
}
const address = await this.prisma.userAddress.update({
where: { id },
data: {
receiverName: body.receiverName ? String(body.receiverName) : undefined,
phone: body.phone ? String(body.phone) : undefined,
province: body.province ? String(body.province) : undefined,
city: body.city ? String(body.city) : undefined,
district: body.district ? String(body.district) : undefined,
detail: body.detail ? String(body.detail) : undefined,
isDefault: body.isDefault ? 1 : undefined,
},
const address = await this.prisma.$transaction(async (tx) => {
if (body.isDefault) {
await tx.userAddress.updateMany({ where: { userId }, data: { isDefault: 0 } });
}
return tx.userAddress.update({
where: { id },
data: {
receiverName: body.receiverName ? String(body.receiverName) : undefined,
phone: body.phone ? String(body.phone) : undefined,
province: body.province ? String(body.province) : undefined,
city: body.city ? String(body.city) : undefined,
district: body.district ? String(body.district) : undefined,
detail: body.detail ? String(body.detail) : undefined,
isDefault: body.isDefault ? 1 : undefined,
},
});
});
return serializeBigInt(address);
}
@@ -2,6 +2,7 @@ import { BadRequestException, Injectable, NotFoundException } from '@nestjs/comm
import { Prisma } from '@prisma/client';
import { PrismaService } from '../../common/prisma/prisma.module';
import { serializeBigInt } from '../../common/decorators/current-user.decorator';
import { groupResourcesByProductId, mapProductMedia } from '../catalog/catalog.mapper';
import type { AdminProductsQueryDto } from './dto/admin-query.dto';
import type { CreateProductDto, UpdateProductDto } from './dto/admin-mutate.dto';
@@ -27,11 +28,23 @@ export class AdminProductsService {
}),
this.prisma.commonProductItem.count({ where }),
]);
const productIds = items.map((p) => p.id);
const resources = productIds.length
? await this.prisma.commonResource.findMany({
where: {
ownerType: 'PRODUCT',
ownerId: { in: productIds },
status: 'ACTIVE',
bizType: { in: ['CAROUSEL', 'DETAIL'] },
},
orderBy: { sortOrder: 'asc' },
})
: [];
const resourceMap = groupResourcesByProductId(resources);
return serializeBigInt({
items: items.map((p) => ({
...p,
mainImageUrl: p.coverResource?.url ?? null,
})),
items: items.map((p) => this.formatProduct(p, resourceMap.get(p.id.toString()) ?? [])),
total,
page,
pageSize,
@@ -44,7 +57,18 @@ export class AdminProductsService {
include: { coverResource: true },
});
if (!product) throw new NotFoundException('商品不存在');
return serializeBigInt({ ...product, mainImageUrl: product.coverResource?.url ?? null });
const resources = await this.prisma.commonResource.findMany({
where: {
ownerType: 'PRODUCT',
ownerId: id,
status: 'ACTIVE',
bizType: { in: ['CAROUSEL', 'DETAIL'] },
},
orderBy: { sortOrder: 'asc' },
});
return serializeBigInt(this.formatProduct(product, resources));
}
async create(dto: CreateProductDto) {
@@ -65,26 +89,19 @@ export class AdminProductsService {
benefitAmount: dto.benefitAmount ?? dto.price,
status: (dto.status ?? 'DRAFT') as 'DRAFT' | 'ON_SALE' | 'OFF_SALE',
sortOrder: dto.sortOrder ?? 0,
...(dto.detailContent !== undefined
? { detailContent: dto.detailContent as Prisma.InputJsonValue }
: {}),
},
});
if (dto.coverUrl) {
const cover = await this.prisma.commonResource.create({
data: {
ownerType: 'PRODUCT',
ownerId: product.id,
bizType: 'COVER',
mediaType: 'IMAGE',
ossBucket: 'legacy',
ossKey: dto.coverUrl,
url: dto.coverUrl,
},
});
await this.prisma.commonProductItem.update({
where: { id: product.id },
data: { coverResourceId: cover.id },
});
await this.syncCover(product.id, dto.coverUrl);
}
await this.syncProductMedia(product.id, {
carouselUrls: dto.carouselUrls,
detailImageUrls: dto.detailImageUrls,
});
return this.detail(product.id);
}
@@ -101,35 +118,96 @@ export class AdminProductsService {
...(dto.benefitAmount !== undefined ? { benefitAmount: dto.benefitAmount } : {}),
...(dto.status !== undefined ? { status: dto.status as 'DRAFT' | 'ON_SALE' | 'OFF_SALE' } : {}),
...(dto.sortOrder !== undefined ? { sortOrder: dto.sortOrder } : {}),
...(dto.detailContent !== undefined
? { detailContent: dto.detailContent as Prisma.InputJsonValue }
: {}),
},
});
if (dto.coverUrl) {
const product = await this.prisma.commonProductItem.findUniqueOrThrow({ where: { id } });
if (product.coverResourceId) {
await this.prisma.commonResource.update({
where: { id: product.coverResourceId },
data: { url: dto.coverUrl, ossKey: dto.coverUrl },
});
} else {
const cover = await this.prisma.commonResource.create({
data: {
ownerType: 'PRODUCT',
ownerId: id,
bizType: 'COVER',
mediaType: 'IMAGE',
ossBucket: 'legacy',
ossKey: dto.coverUrl,
url: dto.coverUrl,
},
});
await this.prisma.commonProductItem.update({
where: { id },
data: { coverResourceId: cover.id },
});
}
await this.syncCover(id, dto.coverUrl);
}
await this.syncProductMedia(id, {
carouselUrls: dto.carouselUrls,
detailImageUrls: dto.detailImageUrls,
});
return this.detail(id);
}
private formatProduct(
product: Prisma.CommonProductItemGetPayload<{ include: { coverResource: true } }>,
extraResources: Prisma.CommonResourceGetPayload<object>[],
) {
const media = mapProductMedia(product, extraResources);
return {
...product,
price: Number(product.price),
benefitAmount: Number(product.benefitAmount ?? product.price),
...media,
};
}
private async syncCover(productId: bigint, coverUrl: string) {
const product = await this.prisma.commonProductItem.findUniqueOrThrow({ where: { id: productId } });
if (product.coverResourceId) {
await this.prisma.commonResource.update({
where: { id: product.coverResourceId },
data: { url: coverUrl, ossKey: coverUrl },
});
} else {
const cover = await this.prisma.commonResource.create({
data: {
ownerType: 'PRODUCT',
ownerId: productId,
bizType: 'COVER',
mediaType: 'IMAGE',
ossBucket: 'legacy',
ossKey: coverUrl,
url: coverUrl,
},
});
await this.prisma.commonProductItem.update({
where: { id: productId },
data: { coverResourceId: cover.id },
});
}
}
private async syncProductMedia(
productId: bigint,
dto: { carouselUrls?: string[]; detailImageUrls?: string[] },
) {
if (dto.carouselUrls !== undefined) {
await this.replaceProductResources(productId, 'CAROUSEL', dto.carouselUrls);
}
if (dto.detailImageUrls !== undefined) {
await this.replaceProductResources(productId, 'DETAIL', dto.detailImageUrls);
}
}
private async replaceProductResources(
productId: bigint,
bizType: 'CAROUSEL' | 'DETAIL',
urls: string[],
) {
const cleaned = urls.map((u) => u?.trim()).filter(Boolean);
await this.prisma.commonResource.deleteMany({
where: { ownerType: 'PRODUCT', ownerId: productId, bizType },
});
if (cleaned.length === 0) return;
await this.prisma.commonResource.createMany({
data: cleaned.map((url, sortOrder) => ({
ownerType: 'PRODUCT' as const,
ownerId: productId,
bizType,
mediaType: 'IMAGE' as const,
ossBucket: 'legacy',
ossKey: url,
url,
sortOrder,
status: 'ACTIVE' as const,
})),
});
}
}
@@ -0,0 +1,20 @@
import { Controller, Get, Param, Query, UseGuards } from '@nestjs/common';
import { HqAuthGuard } from '../../common/guards/hq-auth.guard';
import { AdminUserLogsService } from './admin-user-logs.service';
import { AdminUserLogsQueryDto } from './dto/admin-query.dto';
@Controller('admin/logs/users')
@UseGuards(HqAuthGuard)
export class AdminUserLogsController {
constructor(private readonly service: AdminUserLogsService) {}
@Get()
list(@Query() query: AdminUserLogsQueryDto) {
return this.service.list(query);
}
@Get(':id')
detail(@Param('id') id: string) {
return this.service.detail(BigInt(id));
}
}
@@ -0,0 +1,112 @@
import { Injectable, NotFoundException } from '@nestjs/common';
import { Prisma } from '@prisma/client';
import { eventNamesForUserLogCategory, resolveUserLogCategory } from '@dukang/shared-types';
import { PrismaService } from '../../common/prisma/prisma.module';
import { serializeBigInt } from '../../common/decorators/current-user.decorator';
import type { AdminUserLogsQueryDto } from './dto/admin-query.dto';
@Injectable()
export class AdminUserLogsService {
constructor(private readonly prisma: PrismaService) {}
async list(query: AdminUserLogsQueryDto) {
const page = query.page ?? 1;
const pageSize = query.pageSize ?? 20;
const where: Prisma.LogUserAnalyticsWhereInput = {};
if (query.userId) {
where.userId = BigInt(query.userId);
} else if (query.phone || query.userNo) {
const userWhere: Prisma.UserWhereInput = {};
if (query.phone) userWhere.phone = { contains: query.phone };
if (query.userNo) userWhere.userNo = { contains: query.userNo };
const users = await this.prisma.user.findMany({
where: userWhere,
select: { id: true },
take: 100,
});
if (users.length === 0) {
return { items: [], total: 0, page, pageSize };
}
where.userId = { in: users.map((u) => u.id) };
}
if (query.eventName) {
where.eventName = query.eventName;
} else if (query.category) {
const names = eventNamesForUserLogCategory(query.category);
if (names?.length) {
where.eventName = { in: names };
}
}
if (query.from || query.to) {
where.createdAt = {
...(query.from ? { gte: new Date(query.from) } : {}),
...(query.to ? { lte: new Date(query.to) } : {}),
};
}
const [rows, total] = await Promise.all([
this.prisma.logUserAnalytics.findMany({
where,
orderBy: { createdAt: 'desc' },
skip: (page - 1) * pageSize,
take: pageSize,
}),
this.prisma.logUserAnalytics.count({ where }),
]);
const userIds = [...new Set(rows.map((r) => r.userId).filter((id): id is bigint => id != null))];
const users = userIds.length
? await this.prisma.user.findMany({
where: { id: { in: userIds } },
select: { id: true, userNo: true, phone: true, nickname: true },
})
: [];
const userMap = new Map(users.map((u) => [u.id.toString(), u]));
return serializeBigInt({
items: rows.map((row) => {
const user = row.userId ? userMap.get(row.userId.toString()) : undefined;
return {
id: row.id,
userId: row.userId,
userNo: user?.userNo ?? null,
phone: user?.phone ?? null,
nickname: user?.nickname ?? null,
category: resolveUserLogCategory(row.eventName),
eventName: row.eventName,
clientApp: row.clientApp,
refType: row.refType,
refId: row.refId,
extraJson: row.extraJson,
createdAt: row.createdAt,
};
}),
total,
page,
pageSize,
});
}
async detail(id: bigint) {
const row = await this.prisma.logUserAnalytics.findUnique({ where: { id } });
if (!row) throw new NotFoundException('日志不存在');
const user = row.userId
? await this.prisma.user.findUnique({
where: { id: row.userId },
select: { id: true, userNo: true, phone: true, nickname: true },
})
: null;
return serializeBigInt({
...row,
userNo: user?.userNo ?? null,
phone: user?.phone ?? null,
nickname: user?.nickname ?? null,
category: resolveUserLogCategory(row.eventName),
});
}
}
@@ -1,4 +1,4 @@
import { IsArray, IsIn, IsNotEmpty, IsNumber, IsOptional, IsString } from 'class-validator';
import { IsArray, IsIn, IsNotEmpty, IsNumber, IsObject, IsOptional, IsString } from 'class-validator';
export class UpdateStoreStatusDto {
@IsString()
@@ -391,6 +391,20 @@ export class CreateProductDto {
@IsOptional()
@IsString()
coverUrl?: string;
@IsOptional()
@IsArray()
@IsString({ each: true })
carouselUrls?: string[];
@IsOptional()
@IsArray()
@IsString({ each: true })
detailImageUrls?: string[];
@IsOptional()
@IsObject()
detailContent?: Record<string, unknown>;
}
export class UpdateProductDto {
@@ -425,4 +439,18 @@ export class UpdateProductDto {
@IsOptional()
@IsString()
coverUrl?: string;
@IsOptional()
@IsArray()
@IsString({ each: true })
carouselUrls?: string[];
@IsOptional()
@IsArray()
@IsString({ each: true })
detailImageUrls?: string[];
@IsOptional()
@IsObject()
detailContent?: Record<string, unknown>;
}
@@ -227,6 +227,36 @@ export class AdminProductsQueryDto extends PaginationQueryDto {
aromaType?: string;
}
export class AdminUserLogsQueryDto extends PaginationQueryDto {
@IsOptional()
@IsString()
userId?: string;
@IsOptional()
@IsString()
phone?: string;
@IsOptional()
@IsString()
userNo?: string;
@IsOptional()
@IsString()
category?: string;
@IsOptional()
@IsString()
eventName?: string;
@IsOptional()
@IsString()
from?: string;
@IsOptional()
@IsString()
to?: string;
}
export class AdminStoreMediaQueryDto extends PaginationQueryDto {
@IsOptional()
@IsString()
@@ -21,6 +21,8 @@ import { AdminHqAccountsController } from './admin-hq-accounts.controller';
import { AdminHqAccountsService } from './admin-hq-accounts.service';
import { AdminProductsController } from './admin-products.controller';
import { AdminProductsService } from './admin-products.service';
import { AdminUserLogsController } from './admin-user-logs.controller';
import { AdminUserLogsService } from './admin-user-logs.service';
import { AdminTicketsController } from './admin-tickets.controller';
import { AdminTicketsService } from './admin-tickets.service';
import { SuperAdminGuard } from '../../common/guards/super-admin.guard';
@@ -45,6 +47,7 @@ import { CommonModule } from '../common/common.module';
AdminDeliveriesController,
AdminHqAccountsController,
AdminProductsController,
AdminUserLogsController,
AdminTicketsController,
],
providers: [
@@ -59,6 +62,7 @@ import { CommonModule } from '../common/common.module';
AdminDeliveriesService,
AdminHqAccountsService,
AdminProductsService,
AdminUserLogsService,
AdminTicketsService,
SuperAdminGuard,
],
@@ -1,4 +1,5 @@
import { Module, forwardRef } from '@nestjs/common';
import { AnalyticsModule } from '../analytics/analytics.module';
import { IamModule } from '../iam/iam.module';
import { BenefitModule } from '../benefit/benefit.module';
import { SettlementModule } from '../settlement/settlement.module';
@@ -6,7 +7,7 @@ import { RedeemService } from './redeem.service';
import { ShopRedeemController, UserRedeemController } from './redeem.controller';
@Module({
imports: [IamModule, BenefitModule, forwardRef(() => SettlementModule)],
imports: [IamModule, AnalyticsModule, BenefitModule, forwardRef(() => SettlementModule)],
controllers: [UserRedeemController, ShopRedeemController],
providers: [RedeemService],
exports: [RedeemService],
@@ -14,6 +14,7 @@ import { REDEEM_TOKEN_TTL_SECONDS } from '@dukang/shared-types';
import { PrismaService } from '../../common/prisma/prisma.module';
import { RedisService } from '../../common/redis/redis.service';
import { serializeBigInt } from '../../common/decorators/current-user.decorator';
import { AnalyticsService } from '../analytics/analytics.service';
import { SettlementService } from '../settlement/settlement.service';
import { BenefitService } from '../benefit/benefit.service';
@@ -32,6 +33,7 @@ export class RedeemService {
private readonly redis: RedisService,
private readonly settlementService: SettlementService,
private readonly benefitService: BenefitService,
private readonly analyticsService: AnalyticsService,
) {}
async createToken(userId: bigint, body: { couponId?: string; amount: number; storeId?: string }) {
@@ -197,6 +199,17 @@ export class RedeemService {
await this.settlementService.createStorePayout(record.id, account.storeId, amount, settleAmount, settlementRate);
await this.redis.del(`redeem:token:${body.token}`);
this.analyticsService.trackOneSafe(BigInt(cached.userId), 'SHOP_H5', {
eventName: 'benefit_redeem_success',
refType: 'STORE',
refId: account.storeId,
extraJson: {
redeemRecordId: record.id.toString(),
storeId: account.storeId.toString(),
amount,
},
});
return serializeBigInt(record);
}
@@ -1,4 +1,5 @@
import { Module, forwardRef } from '@nestjs/common';
import { AnalyticsModule } from '../analytics/analytics.module';
import { IntegrationsModule } from '../../integrations/integrations.module';
import { IamModule } from '../iam/iam.module';
import { BenefitModule } from '../benefit/benefit.module';
@@ -8,7 +9,7 @@ import { TradeController, PartnerOrderController, PartnerReshipmentController }
import { TradeService } from './trade.service';
@Module({
imports: [IntegrationsModule, IamModule, CatalogModule, forwardRef(() => BenefitModule), CommonModule],
imports: [IntegrationsModule, IamModule, CatalogModule, AnalyticsModule, forwardRef(() => BenefitModule), CommonModule],
controllers: [TradeController, PartnerOrderController, PartnerReshipmentController],
providers: [TradeService],
exports: [TradeService],
@@ -14,6 +14,7 @@ import {
import { loadAppConfig, WECHAT_AUTH_REQUIRED } from '@dukang/shared-types';
import { PrismaService } from '../../common/prisma/prisma.module';
import { serializeBigInt } from '../../common/decorators/current-user.decorator';
import { AnalyticsService } from '../analytics/analytics.service';
import { CatalogService } from '../catalog/catalog.service';
import { BenefitService } from '../benefit/benefit.service';
import { TicketService } from '../common/ticket.service';
@@ -37,6 +38,7 @@ export class TradeService {
private readonly ipGeoService: IpGeoService,
@Inject(PAY_PROVIDER) private readonly payProvider: IPayProvider,
@Inject(DELIVERY_PROVIDER) private readonly deliveryProvider: IDeliveryProvider,
private readonly analyticsService: AnalyticsService,
) {}
async preview(userId: bigint, body: { productId: string; quantity: number; addressId?: string }) {
@@ -158,6 +160,17 @@ export class TradeService {
include: { product: true, imageResource: true },
});
this.analyticsService.trackOneSafe(userId, 'USER_H5', {
eventName: 'order_submit',
refType: 'ORDER',
refId: order.id,
extraJson: {
orderId: order.id.toString(),
productId: body.productId,
quantity: body.quantity,
},
});
return serializeBigInt(mapOrderCompat(order));
}
@@ -226,6 +239,13 @@ export class TradeService {
await this.benefitService.grantOnOrderPaid(order.id);
await this.deliveryProvider.scheduleAutoAdvance(order.id);
this.analyticsService.trackOneSafe(userId, 'USER_H5', {
eventName: 'pay_success',
refType: 'ORDER',
refId: order.id,
extraJson: { orderId: order.id.toString(), mode: 'mock' },
});
return this.getOrder(userId, orderId);
}
@@ -305,6 +325,12 @@ export class TradeService {
if (refreshed?.payStatus === 'PAID') {
await this.benefitService.grantOnOrderPaid(order.id);
await this.deliveryProvider.scheduleAutoAdvance(order.id);
this.analyticsService.trackOneSafe(order.userId, 'USER_H5', {
eventName: 'pay_success',
refType: 'ORDER',
refId: order.id,
extraJson: { orderId: order.id.toString(), mode: 'wechat_callback' },
});
}
return { orderId: order.id.toString(), alreadyPaid: false };