短信验证调试成功
This commit is contained in:
@@ -1,7 +1,9 @@
|
||||
# 杜康 API 环境变量模板
|
||||
# 开发:cp .env.development.example .env.development → 本地再 cp 为 .env(改 PORT/DATABASE_URL)
|
||||
# 生产:cp .env.production.example .env.production
|
||||
# 服务器同步:bash deploy/sync-api-env.sh development|production
|
||||
# 本地推荐分层:
|
||||
# .env.development — 集成开关与密钥(MOCK_SMS、阿里云、微信)
|
||||
# .env — 仅本机项(PORT、DATABASE_URL、REDIS_URL)
|
||||
# .env.local — 可选本机覆盖(gitignore)
|
||||
# 生产/预发:bash deploy/sync-api-env.sh development|production
|
||||
|
||||
DATABASE_URL="mysql://root:root@localhost:3306/dukang_haoke"
|
||||
REDIS_URL="redis://localhost:6379"
|
||||
@@ -10,6 +12,11 @@ JWT_EXPIRES_IN="7d"
|
||||
PORT=3000
|
||||
MOCK_SMS=true
|
||||
MOCK_SMS_CODE=123456
|
||||
# MOCK_SMS=false 时必填(可与 OSS 共用 RAM)
|
||||
ALIYUN_SMS_SIGN_NAME=
|
||||
ALIYUN_SMS_TEMPLATE_CODE=
|
||||
ALIYUN_SMS_ACCESS_KEY_ID=
|
||||
ALIYUN_SMS_ACCESS_KEY_SECRET=
|
||||
MOCK_PAY=true
|
||||
MOCK_DELIVERY_AUTO=true
|
||||
AUTO_APPROVE_STORE=true
|
||||
|
||||
@@ -12,6 +12,10 @@ PORT=8090
|
||||
|
||||
MOCK_SMS=false
|
||||
MOCK_SMS_CODE=
|
||||
ALIYUN_SMS_SIGN_NAME=
|
||||
ALIYUN_SMS_TEMPLATE_CODE=
|
||||
ALIYUN_SMS_ACCESS_KEY_ID=
|
||||
ALIYUN_SMS_ACCESS_KEY_SECRET=
|
||||
MOCK_PAY=false
|
||||
MOCK_DELIVERY_AUTO=false
|
||||
AUTO_APPROVE_STORE=false
|
||||
|
||||
@@ -15,6 +15,8 @@
|
||||
"prisma:sync-benefit": "ts-node --transpile-only prisma/sync-benefit-to-price.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@alicloud/dysmsapi20170525": "^4.6.0",
|
||||
"@alicloud/openapi-client": "^0.4.15",
|
||||
"@dukang/domain": "workspace:*",
|
||||
"@dukang/shared-types": "workspace:*",
|
||||
"@nestjs/bullmq": "^10.2.0",
|
||||
@@ -28,6 +30,7 @@
|
||||
"bullmq": "^5.12.0",
|
||||
"class-transformer": "^0.5.1",
|
||||
"class-validator": "^0.14.1",
|
||||
"dotenv": "^17.4.2",
|
||||
"express": "^4.21.0",
|
||||
"ioredis": "^5.4.1",
|
||||
"ip2region": "^2.3.0",
|
||||
|
||||
@@ -20,7 +20,7 @@ import { CallbacksModule } from './callbacks/callbacks.module';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
ConfigModule.forRoot({ isGlobal: true }),
|
||||
ConfigModule.forRoot({ isGlobal: true, ignoreEnvFile: true }),
|
||||
BullModule.forRoot({
|
||||
connection: {
|
||||
url: process.env.REDIS_URL || 'redis://localhost:6379',
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { BullModule } from '@nestjs/bullmq';
|
||||
import { loadAppConfig } from '@dukang/shared-types';
|
||||
import { SmsCodeStore } from './sms/sms-code.store';
|
||||
import { SmsMockProvider } from './sms/sms.mock.provider';
|
||||
import { SmsAliyunProvider } from './sms/sms.aliyun.provider';
|
||||
import { PayMockProvider } from './pay/pay.mock.provider';
|
||||
import { PayWechatProvider } from './pay/pay.wechat.provider';
|
||||
import { DeliveryMockProvider } from './delivery/delivery.mock.provider';
|
||||
@@ -21,11 +23,26 @@ import { DELIVERY_QUEUE } from '../jobs/jobs.constants';
|
||||
import type { IWechatProvider } from './wechat/wechat.interface';
|
||||
import type { IPayProvider } from './pay/pay.interface';
|
||||
import type { IOssProvider } from './oss/oss.interface';
|
||||
import type { ISmsProvider } from './sms/sms.interface';
|
||||
|
||||
@Module({
|
||||
imports: [BullModule.registerQueue({ name: DELIVERY_QUEUE }), CourierModule],
|
||||
providers: [
|
||||
{ provide: SMS_PROVIDER, useClass: SmsMockProvider },
|
||||
SmsCodeStore,
|
||||
SmsMockProvider,
|
||||
SmsAliyunProvider,
|
||||
{
|
||||
provide: SMS_PROVIDER,
|
||||
useFactory: (mock: SmsMockProvider, aliyun: SmsAliyunProvider): ISmsProvider => {
|
||||
const cfg = loadAppConfig();
|
||||
if (cfg.mockSms) return mock;
|
||||
if (!aliyun.isEnabled()) {
|
||||
throw new Error('MOCK_SMS=false but Aliyun SMS credentials are missing');
|
||||
}
|
||||
return aliyun;
|
||||
},
|
||||
inject: [SmsMockProvider, SmsAliyunProvider],
|
||||
},
|
||||
WechatApiProvider,
|
||||
WechatDisabledProvider,
|
||||
{
|
||||
@@ -60,9 +77,8 @@ import type { IOssProvider } from './oss/oss.interface';
|
||||
},
|
||||
inject: [OssMockProvider, OssAliyunProvider],
|
||||
},
|
||||
SmsMockProvider,
|
||||
DeliveryMockProvider,
|
||||
],
|
||||
exports: [SMS_PROVIDER, PAY_PROVIDER, DELIVERY_PROVIDER, WECHAT_PROVIDER, OSS_PROVIDER, CourierModule],
|
||||
exports: [SMS_PROVIDER, SmsCodeStore, PAY_PROVIDER, DELIVERY_PROVIDER, WECHAT_PROVIDER, OSS_PROVIDER, CourierModule],
|
||||
})
|
||||
export class IntegrationsModule {}
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
import { BadRequestException, Injectable } from '@nestjs/common';
|
||||
import { loadAppConfig } from '@dukang/shared-types';
|
||||
import { RedisService } from '../../common/redis/redis.service';
|
||||
|
||||
const CODE_TTL_SECONDS = 300;
|
||||
const RATE_TTL_SECONDS = 60;
|
||||
|
||||
function codeKey(phone: string, scene: string) {
|
||||
return `dukang:sms:${scene}:${phone}`;
|
||||
}
|
||||
|
||||
function rateKey(phone: string) {
|
||||
return `dukang:sms:rate:${phone}`;
|
||||
}
|
||||
|
||||
function randomSixDigitCode() {
|
||||
return String(Math.floor(100000 + Math.random() * 900000));
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class SmsCodeStore {
|
||||
private readonly config = loadAppConfig();
|
||||
|
||||
constructor(private readonly redis: RedisService) {}
|
||||
|
||||
async assertSendCooldown(phone: string) {
|
||||
const ttl = await this.redis.ttl(rateKey(phone));
|
||||
if (ttl > 0) {
|
||||
throw new BadRequestException('发送过于频繁,请稍后再试');
|
||||
}
|
||||
}
|
||||
|
||||
async setSendCooldown(phone: string) {
|
||||
await this.redis.client.set(rateKey(phone), '1', 'EX', RATE_TTL_SECONDS);
|
||||
}
|
||||
|
||||
async generateAndStore(phone: string, scene: string): Promise<string> {
|
||||
const code = this.config.mockSms ? this.config.mockSmsCode : randomSixDigitCode();
|
||||
await this.redis.client.set(codeKey(phone, scene), code, 'EX', CODE_TTL_SECONDS);
|
||||
return code;
|
||||
}
|
||||
|
||||
async verifyAndConsume(phone: string, scene: string, code: string) {
|
||||
const key = codeKey(phone, scene);
|
||||
const stored = await this.redis.client.get(key);
|
||||
if (!stored || stored !== code.trim()) {
|
||||
throw new BadRequestException('验证码错误或已过期');
|
||||
}
|
||||
await this.redis.del(key);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
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 { SmsCodeStore } from './sms-code.store';
|
||||
|
||||
function serializeSmsResponseBody(body: unknown): Record<string, string> | undefined {
|
||||
if (!body || typeof body !== 'object') return undefined;
|
||||
const src = body as Record<string, unknown>;
|
||||
const out: Record<string, string> = {};
|
||||
for (const key of ['code', 'message', 'requestId', 'bizId'] as const) {
|
||||
const value = src[key];
|
||||
if (value != null && typeof value !== 'function') {
|
||||
out[key] = String(value);
|
||||
}
|
||||
}
|
||||
return Object.keys(out).length ? out : undefined;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class SmsAliyunProvider implements ISmsProvider {
|
||||
private readonly logger = new Logger(SmsAliyunProvider.name);
|
||||
private readonly config = loadAppConfig();
|
||||
private client: Dysmsapi20170525 | null = null;
|
||||
|
||||
constructor(
|
||||
private readonly smsCodeStore: SmsCodeStore,
|
||||
private readonly prisma: PrismaService,
|
||||
) {}
|
||||
|
||||
isEnabled() {
|
||||
return !!(
|
||||
this.config.aliyunSmsAccessKeyId &&
|
||||
this.config.aliyunSmsAccessKeySecret &&
|
||||
this.config.aliyunSmsSignName &&
|
||||
this.config.aliyunSmsTemplateCode
|
||||
);
|
||||
}
|
||||
|
||||
private getClient() {
|
||||
if (!this.isEnabled()) {
|
||||
throw new Error('Aliyun SMS is not configured');
|
||||
}
|
||||
if (!this.client) {
|
||||
const openApiConfig = new OpenApi.Config({
|
||||
accessKeyId: this.config.aliyunSmsAccessKeyId,
|
||||
accessKeySecret: this.config.aliyunSmsAccessKeySecret,
|
||||
endpoint: 'dysmsapi.aliyuncs.com',
|
||||
});
|
||||
this.client = new Dysmsapi20170525(openApiConfig);
|
||||
}
|
||||
return this.client;
|
||||
}
|
||||
|
||||
async send(phone: string, scene: string): Promise<void> {
|
||||
const code = await this.smsCodeStore.generateAndStore(phone, scene);
|
||||
const request = new SendSmsRequest({
|
||||
phoneNumbers: phone,
|
||||
signName: this.config.aliyunSmsSignName,
|
||||
templateCode: this.config.aliyunSmsTemplateCode,
|
||||
templateParam: JSON.stringify({ code }),
|
||||
});
|
||||
|
||||
let logged = false;
|
||||
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({
|
||||
data: {
|
||||
provider: 'SMS',
|
||||
scene,
|
||||
requestBody: { phone, templateCode: this.config.aliyunSmsTemplateCode, signName: this.config.aliyunSmsSignName },
|
||||
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 ?? '短信发送失败');
|
||||
}
|
||||
this.logger.log(`Aliyun SMS sent to ${phone.slice(0, 3)}****${phone.slice(-4)} scene=${scene} bizId=${bizId ?? '-'}`);
|
||||
} 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;
|
||||
}
|
||||
}
|
||||
|
||||
async verify(phone: string, code: string, scene: string): Promise<void> {
|
||||
await this.smsCodeStore.verifyAndConsume(phone, scene, code);
|
||||
}
|
||||
}
|
||||
@@ -1,21 +1,34 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { loadAppConfig } from '@dukang/shared-types';
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { PrismaService } from '../../common/prisma/prisma.module';
|
||||
import { ISmsProvider } from './sms.interface';
|
||||
import { SmsCodeStore } from './sms-code.store';
|
||||
|
||||
@Injectable()
|
||||
export class SmsMockProvider implements ISmsProvider {
|
||||
private readonly config = loadAppConfig();
|
||||
private readonly logger = new Logger(SmsMockProvider.name);
|
||||
|
||||
async send(_phone: string, _scene: string): Promise<void> {
|
||||
if (!this.config.mockSms) {
|
||||
throw new Error('Real SMS not implemented in preV1');
|
||||
}
|
||||
constructor(
|
||||
private readonly smsCodeStore: SmsCodeStore,
|
||||
private readonly prisma: PrismaService,
|
||||
) {}
|
||||
|
||||
async send(phone: string, scene: string): Promise<void> {
|
||||
const code = await this.smsCodeStore.generateAndStore(phone, scene);
|
||||
const masked = `${phone.slice(0, 3)}****${phone.slice(-4)}`;
|
||||
this.logger.log(`Mock SMS → ${masked} scene=${scene} code=${code}`);
|
||||
|
||||
await this.prisma.logThirdParty.create({
|
||||
data: {
|
||||
provider: 'SMS',
|
||||
scene,
|
||||
requestBody: { phone: masked, mode: 'MOCK', scene },
|
||||
responseBody: { mock: true, hint: 'use MOCK_SMS_CODE or check server log' },
|
||||
status: 'SUCCESS',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async verify(phone: string, code: string, _scene: string): Promise<void> {
|
||||
if (this.config.mockSms && code === this.config.mockSmsCode) {
|
||||
return;
|
||||
}
|
||||
throw new Error(`Invalid verification code for ${phone}`);
|
||||
async verify(phone: string, code: string, scene: string): Promise<void> {
|
||||
await this.smsCodeStore.verifyAndConsume(phone, scene, code);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
import { config } from 'dotenv';
|
||||
import { existsSync } from 'fs';
|
||||
import { resolve } from 'path';
|
||||
|
||||
/**
|
||||
* 分层加载环境变量(后加载的文件覆盖先前的同名键):
|
||||
* 1. .env.{NODE_ENV} — 集成配置(MOCK_SMS、微信、阿里云等)
|
||||
* 2. .env.{NODE_ENV}.local / .env.local — 本机覆盖
|
||||
* 3. .env — 本机基础(PORT、DATABASE_URL);应只放机器相关项,勿重复 MOCK_SMS
|
||||
*/
|
||||
const apiRoot = resolve(__dirname, '..');
|
||||
const nodeEnv = process.env.NODE_ENV ?? 'development';
|
||||
|
||||
const layers = [
|
||||
resolve(apiRoot, `.env.${nodeEnv}`),
|
||||
resolve(apiRoot, `.env.${nodeEnv}.local`),
|
||||
resolve(apiRoot, '.env.local'),
|
||||
resolve(apiRoot, '.env'),
|
||||
];
|
||||
|
||||
for (const file of layers) {
|
||||
if (existsSync(file)) {
|
||||
config({ path: file, override: true });
|
||||
}
|
||||
}
|
||||
|
||||
if (!process.env.NODE_ENV) {
|
||||
process.env.NODE_ENV = nodeEnv;
|
||||
}
|
||||
@@ -1,4 +1,6 @@
|
||||
import './load-env';
|
||||
import { NestFactory } from '@nestjs/core';
|
||||
import { loadAppConfig } from '@dukang/shared-types';
|
||||
import { NestExpressApplication } from '@nestjs/platform-express';
|
||||
import { ValidationPipe } from '@nestjs/common';
|
||||
import { json } from 'express';
|
||||
@@ -24,6 +26,9 @@ async function bootstrap() {
|
||||
app.useGlobalFilters(new HttpExceptionFilter());
|
||||
app.useGlobalInterceptors(new ResponseInterceptor());
|
||||
const port = process.env.PORT || 3000;
|
||||
const cfg = loadAppConfig();
|
||||
const smsMode = cfg.mockSms ? 'MOCK' : 'ALIYUN';
|
||||
console.log(`[config] NODE_ENV=${process.env.NODE_ENV} MOCK_SMS=${cfg.mockSms} SMS=${smsMode}`);
|
||||
await app.listen(port);
|
||||
console.log(`dukang-api listening on http://localhost:${port}/api/v1`);
|
||||
}
|
||||
|
||||
@@ -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 };
|
||||
|
||||
Reference in New Issue
Block a user