init
This commit is contained in:
@@ -0,0 +1,39 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { ConfigModule } from '@nestjs/config';
|
||||
import { BullModule } from '@nestjs/bullmq';
|
||||
import { PrismaModule } from './common/prisma/prisma.module';
|
||||
import { RedisModule } from './common/redis/redis.module';
|
||||
import { HealthModule } from './modules/health/health.module';
|
||||
import { IamModule } from './modules/iam/iam.module';
|
||||
import { CatalogModule } from './modules/catalog/catalog.module';
|
||||
import { TradeModule } from './modules/trade/trade.module';
|
||||
import { BenefitModule } from './modules/benefit/benefit.module';
|
||||
import { StoreModule } from './modules/store/store.module';
|
||||
import { RedeemModule } from './modules/redeem/redeem.module';
|
||||
import { SettlementModule } from './modules/settlement/settlement.module';
|
||||
import { AnalyticsModule } from './modules/analytics/analytics.module';
|
||||
import { JobsModule } from './jobs/jobs.module';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
ConfigModule.forRoot({ isGlobal: true }),
|
||||
BullModule.forRoot({
|
||||
connection: {
|
||||
url: process.env.REDIS_URL || 'redis://localhost:6379',
|
||||
},
|
||||
}),
|
||||
PrismaModule,
|
||||
RedisModule,
|
||||
HealthModule,
|
||||
IamModule,
|
||||
CatalogModule,
|
||||
TradeModule,
|
||||
BenefitModule,
|
||||
StoreModule,
|
||||
RedeemModule,
|
||||
SettlementModule,
|
||||
AnalyticsModule,
|
||||
JobsModule,
|
||||
],
|
||||
})
|
||||
export class AppModule {}
|
||||
@@ -0,0 +1,14 @@
|
||||
import { createParamDecorator, ExecutionContext } from '@nestjs/common';
|
||||
import { AuthUser } from '../guards/jwt-auth.guard';
|
||||
|
||||
export const CurrentUser = createParamDecorator(
|
||||
(_data: unknown, ctx: ExecutionContext): AuthUser => {
|
||||
return ctx.switchToHttp().getRequest().user;
|
||||
},
|
||||
);
|
||||
|
||||
export function serializeBigInt<T>(value: T): T {
|
||||
return JSON.parse(
|
||||
JSON.stringify(value, (_k, v) => (typeof v === 'bigint' ? v.toString() : v)),
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import {
|
||||
ArgumentsHost,
|
||||
Catch,
|
||||
ExceptionFilter,
|
||||
HttpException,
|
||||
HttpStatus,
|
||||
} from '@nestjs/common';
|
||||
|
||||
@Catch()
|
||||
export class HttpExceptionFilter implements ExceptionFilter {
|
||||
catch(exception: unknown, host: ArgumentsHost) {
|
||||
const ctx = host.switchToHttp();
|
||||
const response = ctx.getResponse();
|
||||
|
||||
if (exception instanceof HttpException) {
|
||||
const status = exception.getStatus();
|
||||
const res = exception.getResponse();
|
||||
const message =
|
||||
typeof res === 'string'
|
||||
? res
|
||||
: (res as { message?: string | string[] }).message || exception.message;
|
||||
response.status(status).json({
|
||||
code: status,
|
||||
message: Array.isArray(message) ? message.join(', ') : message,
|
||||
data: null,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
console.error(exception);
|
||||
response.status(HttpStatus.INTERNAL_SERVER_ERROR).json({
|
||||
code: 500,
|
||||
message: 'Internal server error',
|
||||
data: null,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import {
|
||||
CanActivate,
|
||||
ExecutionContext,
|
||||
Injectable,
|
||||
UnauthorizedException,
|
||||
} from '@nestjs/common';
|
||||
import { JwtService } from '@nestjs/jwt';
|
||||
import { CLIENT_APP_ACTOR_MAP, ClientApp } from '@dukang/shared-types';
|
||||
|
||||
export interface AuthUser {
|
||||
actorType: string;
|
||||
actorId: bigint;
|
||||
clientApp: ClientApp;
|
||||
sub: string;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class JwtAuthGuard implements CanActivate {
|
||||
constructor(protected readonly jwtService: JwtService) {}
|
||||
|
||||
canActivate(context: ExecutionContext): boolean {
|
||||
const req = context.switchToHttp().getRequest();
|
||||
const auth = req.headers.authorization as string | undefined;
|
||||
if (!auth?.startsWith('Bearer ')) {
|
||||
throw new UnauthorizedException('Missing token');
|
||||
}
|
||||
try {
|
||||
const payload = this.jwtService.verify(auth.slice(7));
|
||||
const clientApp = req.headers['x-client-app'] as ClientApp;
|
||||
if (!clientApp || payload.clientApp !== clientApp) {
|
||||
throw new UnauthorizedException('Invalid client app');
|
||||
}
|
||||
const expectedActor = CLIENT_APP_ACTOR_MAP[clientApp];
|
||||
if (payload.actorType !== expectedActor) {
|
||||
throw new UnauthorizedException('Actor mismatch');
|
||||
}
|
||||
req.user = {
|
||||
actorType: payload.actorType,
|
||||
actorId: BigInt(payload.actorId),
|
||||
clientApp,
|
||||
sub: payload.sub,
|
||||
} satisfies AuthUser;
|
||||
return true;
|
||||
} catch (err) {
|
||||
if (err instanceof UnauthorizedException) throw err;
|
||||
throw new UnauthorizedException('Invalid token');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import {
|
||||
CallHandler,
|
||||
ExecutionContext,
|
||||
Injectable,
|
||||
NestInterceptor,
|
||||
} from '@nestjs/common';
|
||||
import { Observable, map } from 'rxjs';
|
||||
|
||||
@Injectable()
|
||||
export class ResponseInterceptor implements NestInterceptor {
|
||||
intercept(_context: ExecutionContext, next: CallHandler): Observable<unknown> {
|
||||
return next.handle().pipe(
|
||||
map((data) => ({
|
||||
code: 0,
|
||||
message: 'ok',
|
||||
data: data ?? null,
|
||||
})),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import { Global, Injectable, Module, OnModuleInit } from '@nestjs/common';
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
|
||||
@Injectable()
|
||||
export class PrismaService extends PrismaClient implements OnModuleInit {
|
||||
async onModuleInit() {
|
||||
await this.$connect();
|
||||
}
|
||||
}
|
||||
|
||||
@Global()
|
||||
@Module({
|
||||
providers: [PrismaService],
|
||||
exports: [PrismaService],
|
||||
})
|
||||
export class PrismaModule {}
|
||||
@@ -0,0 +1,16 @@
|
||||
import { Global, Module } from '@nestjs/common';
|
||||
import Redis from 'ioredis';
|
||||
import { RedisService } from './redis.service';
|
||||
|
||||
@Global()
|
||||
@Module({
|
||||
providers: [
|
||||
{
|
||||
provide: 'REDIS_CLIENT',
|
||||
useFactory: () => new Redis(process.env.REDIS_URL || 'redis://localhost:6379'),
|
||||
},
|
||||
RedisService,
|
||||
],
|
||||
exports: ['REDIS_CLIENT', RedisService],
|
||||
})
|
||||
export class RedisModule {}
|
||||
@@ -0,0 +1,29 @@
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import Redis from 'ioredis';
|
||||
|
||||
@Injectable()
|
||||
export class RedisService {
|
||||
constructor(@Inject('REDIS_CLIENT') private readonly redis: Redis) {}
|
||||
|
||||
get client() {
|
||||
return this.redis;
|
||||
}
|
||||
|
||||
async setJson(key: string, value: unknown, ttlSeconds?: number) {
|
||||
const payload = JSON.stringify(value);
|
||||
if (ttlSeconds) {
|
||||
await this.redis.set(key, payload, 'EX', ttlSeconds);
|
||||
} else {
|
||||
await this.redis.set(key, payload);
|
||||
}
|
||||
}
|
||||
|
||||
async getJson<T>(key: string): Promise<T | null> {
|
||||
const raw = await this.redis.get(key);
|
||||
return raw ? (JSON.parse(raw) as T) : null;
|
||||
}
|
||||
|
||||
async del(key: string) {
|
||||
await this.redis.del(key);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
export interface IDeliveryProvider {
|
||||
scheduleAutoAdvance(orderId: bigint): Promise<void>;
|
||||
advanceTo(orderId: bigint, targetStatus: string): Promise<void>;
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { InjectQueue } from '@nestjs/bullmq';
|
||||
import { Queue } from 'bullmq';
|
||||
import { loadAppConfig } from '@dukang/shared-types';
|
||||
import { IDeliveryProvider } from './delivery.interface';
|
||||
import { DELIVERY_QUEUE } from '../../jobs/jobs.constants';
|
||||
|
||||
@Injectable()
|
||||
export class DeliveryMockProvider implements IDeliveryProvider {
|
||||
private readonly config = loadAppConfig();
|
||||
|
||||
constructor(@InjectQueue(DELIVERY_QUEUE) private readonly queue: Queue) {}
|
||||
|
||||
async scheduleAutoAdvance(orderId: bigint): Promise<void> {
|
||||
if (!this.config.mockDeliveryAuto) return;
|
||||
const steps = [
|
||||
{ delay: 0, status: 'OUT_WAREHOUSE' },
|
||||
{ delay: 10000, status: 'SHIPPING' },
|
||||
{ delay: 30000, status: 'PENDING_RECEIVE' },
|
||||
{ delay: 60000, status: 'COMPLETED' },
|
||||
];
|
||||
for (const step of steps) {
|
||||
await this.queue.add(
|
||||
'advance-status',
|
||||
{ orderId: orderId.toString(), targetStatus: step.status },
|
||||
{ delay: step.delay, jobId: `${orderId}-${step.status}` },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async advanceTo(orderId: bigint, targetStatus: string): Promise<void> {
|
||||
await this.queue.add('advance-status', {
|
||||
orderId: orderId.toString(),
|
||||
targetStatus,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export const SMS_PROVIDER = 'SMS_PROVIDER';
|
||||
export const PAY_PROVIDER = 'PAY_PROVIDER';
|
||||
export const DELIVERY_PROVIDER = 'DELIVERY_PROVIDER';
|
||||
@@ -0,0 +1,21 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { BullModule } from '@nestjs/bullmq';
|
||||
import { SmsMockProvider } from './sms/sms.mock.provider';
|
||||
import { PayMockProvider } from './pay/pay.mock.provider';
|
||||
import { DeliveryMockProvider } from './delivery/delivery.mock.provider';
|
||||
import { SMS_PROVIDER, PAY_PROVIDER, DELIVERY_PROVIDER } from './integrations.constants';
|
||||
import { DELIVERY_QUEUE } from '../jobs/jobs.constants';
|
||||
|
||||
@Module({
|
||||
imports: [BullModule.registerQueue({ name: DELIVERY_QUEUE })],
|
||||
providers: [
|
||||
{ provide: SMS_PROVIDER, useClass: SmsMockProvider },
|
||||
{ provide: PAY_PROVIDER, useClass: PayMockProvider },
|
||||
{ provide: DELIVERY_PROVIDER, useClass: DeliveryMockProvider },
|
||||
SmsMockProvider,
|
||||
PayMockProvider,
|
||||
DeliveryMockProvider,
|
||||
],
|
||||
exports: [SMS_PROVIDER, PAY_PROVIDER, DELIVERY_PROVIDER],
|
||||
})
|
||||
export class IntegrationsModule {}
|
||||
@@ -0,0 +1,3 @@
|
||||
export interface IPayProvider {
|
||||
payOrder(orderId: bigint): Promise<{ externalNo: string }>;
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { loadAppConfig } from '@dukang/shared-types';
|
||||
import { IPayProvider } from './pay.interface';
|
||||
|
||||
@Injectable()
|
||||
export class PayMockProvider implements IPayProvider {
|
||||
private readonly config = loadAppConfig();
|
||||
|
||||
async payOrder(_orderId: bigint): Promise<{ externalNo: string }> {
|
||||
if (!this.config.mockPay) {
|
||||
throw new Error('Real WeChat pay not implemented in preV1');
|
||||
}
|
||||
return { externalNo: `MOCK-${Date.now()}` };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
export interface ISmsProvider {
|
||||
send(phone: string, scene: string): Promise<void>;
|
||||
verify(phone: string, code: string, scene: string): Promise<void>;
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { loadAppConfig } from '@dukang/shared-types';
|
||||
import { ISmsProvider } from './sms.interface';
|
||||
|
||||
@Injectable()
|
||||
export class SmsMockProvider implements ISmsProvider {
|
||||
private readonly config = loadAppConfig();
|
||||
|
||||
async send(_phone: string, _scene: string): Promise<void> {
|
||||
if (!this.config.mockSms) {
|
||||
throw new Error('Real SMS not implemented in preV1');
|
||||
}
|
||||
}
|
||||
|
||||
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}`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import { Processor, WorkerHost } from '@nestjs/bullmq';
|
||||
import { Job } from 'bullmq';
|
||||
import { TradeService } from '../modules/trade/trade.service';
|
||||
import { DELIVERY_QUEUE } from './jobs.constants';
|
||||
|
||||
@Processor(DELIVERY_QUEUE)
|
||||
export class DeliveryProcessor extends WorkerHost {
|
||||
constructor(private readonly tradeService: TradeService) {
|
||||
super();
|
||||
}
|
||||
|
||||
async process(job: Job<{ orderId: string; targetStatus: string }>) {
|
||||
await this.tradeService.applyStatusTransition(
|
||||
BigInt(job.data.orderId),
|
||||
'',
|
||||
job.data.targetStatus,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export const DELIVERY_QUEUE = 'delivery-mock';
|
||||
@@ -0,0 +1,11 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { BullModule } from '@nestjs/bullmq';
|
||||
import { TradeModule } from '../modules/trade/trade.module';
|
||||
import { DeliveryProcessor } from './delivery.processor';
|
||||
import { DELIVERY_QUEUE } from './jobs.constants';
|
||||
|
||||
@Module({
|
||||
imports: [BullModule.registerQueue({ name: DELIVERY_QUEUE }), TradeModule],
|
||||
providers: [DeliveryProcessor],
|
||||
})
|
||||
export class JobsModule {}
|
||||
@@ -0,0 +1,19 @@
|
||||
import { NestFactory } from '@nestjs/core';
|
||||
import { ValidationPipe } from '@nestjs/common';
|
||||
import { AppModule } from './app.module';
|
||||
import { HttpExceptionFilter } from './common/filters/http-exception.filter';
|
||||
import { ResponseInterceptor } from './common/interceptors/response.interceptor';
|
||||
|
||||
async function bootstrap() {
|
||||
const app = await NestFactory.create(AppModule);
|
||||
app.setGlobalPrefix('api/v1');
|
||||
app.enableCors({ origin: true, credentials: true });
|
||||
app.useGlobalPipes(new ValidationPipe({ transform: true, whitelist: true }));
|
||||
app.useGlobalFilters(new HttpExceptionFilter());
|
||||
app.useGlobalInterceptors(new ResponseInterceptor());
|
||||
const port = process.env.PORT || 3000;
|
||||
await app.listen(port);
|
||||
console.log(`dukang-api listening on http://localhost:${port}/api/v1`);
|
||||
}
|
||||
|
||||
bootstrap();
|
||||
@@ -0,0 +1,15 @@
|
||||
import { Body, Controller, Post, UseGuards } from '@nestjs/common';
|
||||
import { AnalyticsService } from './analytics.service';
|
||||
import { JwtAuthGuard, AuthUser } from '../../common/guards/jwt-auth.guard';
|
||||
import { CurrentUser } from '../../common/decorators/current-user.decorator';
|
||||
|
||||
@Controller('analytics')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
export class AnalyticsController {
|
||||
constructor(private readonly analyticsService: AnalyticsService) {}
|
||||
|
||||
@Post('events')
|
||||
track(@CurrentUser() user: AuthUser, @Body() body: { events: Array<{ eventName: string; params?: Record<string, unknown> }> }) {
|
||||
return this.analyticsService.trackBatch(user.actorId, user.clientApp, body.events);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { IamModule } from '../iam/iam.module';
|
||||
import { AnalyticsController } from './analytics.controller';
|
||||
import { AnalyticsService } from './analytics.service';
|
||||
|
||||
@Module({
|
||||
imports: [IamModule],
|
||||
controllers: [AnalyticsController],
|
||||
providers: [AnalyticsService],
|
||||
})
|
||||
export class AnalyticsModule {}
|
||||
@@ -0,0 +1,24 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { PrismaService } from '../../common/prisma/prisma.module';
|
||||
|
||||
@Injectable()
|
||||
export class AnalyticsService {
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
async trackBatch(
|
||||
userId: bigint,
|
||||
clientApp: string,
|
||||
events: Array<{ eventName: string; params?: Record<string, unknown> }>,
|
||||
) {
|
||||
if (!events?.length) return { count: 0 };
|
||||
await this.prisma.eventLog.createMany({
|
||||
data: events.map((e) => ({
|
||||
userId,
|
||||
eventName: e.eventName,
|
||||
params: e.params as never,
|
||||
clientApp,
|
||||
})),
|
||||
});
|
||||
return { count: events.length };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import { Controller, Get, Param, Query, UseGuards } from '@nestjs/common';
|
||||
import { BenefitService } from './benefit.service';
|
||||
import { JwtAuthGuard, AuthUser } from '../../common/guards/jwt-auth.guard';
|
||||
import { CurrentUser } from '../../common/decorators/current-user.decorator';
|
||||
|
||||
@Controller('benefit')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
export class BenefitController {
|
||||
constructor(private readonly benefitService: BenefitService) {}
|
||||
|
||||
@Get('coupons')
|
||||
coupons(@CurrentUser() user: AuthUser) {
|
||||
return this.benefitService.listCoupons(user.actorId);
|
||||
}
|
||||
|
||||
@Get('summary')
|
||||
summary(@CurrentUser() user: AuthUser) {
|
||||
return this.benefitService.getSummary(user.actorId);
|
||||
}
|
||||
|
||||
@Get('coupons/:id')
|
||||
coupon(@CurrentUser() user: AuthUser, @Param('id') id: string) {
|
||||
return this.benefitService.getCoupon(user.actorId, BigInt(id));
|
||||
}
|
||||
|
||||
@Get('ledger')
|
||||
ledger(@CurrentUser() user: AuthUser, @Query('couponId') couponId?: string) {
|
||||
return this.benefitService.getLedger(
|
||||
user.actorId,
|
||||
couponId ? BigInt(couponId) : undefined,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { IamModule } from '../iam/iam.module';
|
||||
import { BenefitController } from './benefit.controller';
|
||||
import { BenefitService } from './benefit.service';
|
||||
|
||||
@Module({
|
||||
imports: [IamModule],
|
||||
controllers: [BenefitController],
|
||||
providers: [BenefitService],
|
||||
exports: [BenefitService],
|
||||
})
|
||||
export class BenefitModule {}
|
||||
@@ -0,0 +1,92 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { calcBenefitAmount, calcBenefitSummary, generateCouponNo } from '@dukang/domain';
|
||||
import { REDEEM_MAX_AMOUNT } from '@dukang/shared-types';
|
||||
import { PrismaService } from '../../common/prisma/prisma.module';
|
||||
import { serializeBigInt } from '../../common/decorators/current-user.decorator';
|
||||
|
||||
@Injectable()
|
||||
export class BenefitService {
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
async grantOnOrderPaid(orderId: bigint) {
|
||||
const order = await this.prisma.order.findUniqueOrThrow({
|
||||
where: { id: orderId },
|
||||
include: { items: true },
|
||||
});
|
||||
const item = order.items[0];
|
||||
if (!item) return null;
|
||||
|
||||
const product = await this.prisma.product.findUnique({ where: { id: item.productId } });
|
||||
const unitBenefit = calcBenefitAmount({
|
||||
price: Number(item.unitPrice),
|
||||
benefitAmount: product?.benefitAmount ? Number(product.benefitAmount) : null,
|
||||
});
|
||||
const totalBenefit = unitBenefit * item.quantity;
|
||||
|
||||
const coupon = await this.prisma.benefitCoupon.create({
|
||||
data: {
|
||||
couponNo: generateCouponNo(),
|
||||
userId: order.userId,
|
||||
orderId: order.id,
|
||||
totalAmount: totalBenefit,
|
||||
balance: totalBenefit,
|
||||
sourceProduct: item.productName,
|
||||
},
|
||||
});
|
||||
|
||||
await this.prisma.benefitLedger.create({
|
||||
data: {
|
||||
userId: order.userId,
|
||||
couponId: coupon.id,
|
||||
type: 'GRANT',
|
||||
amount: totalBenefit,
|
||||
balanceAfter: totalBenefit,
|
||||
refType: 'ORDER',
|
||||
refId: order.id,
|
||||
remark: '购酒赠券',
|
||||
},
|
||||
});
|
||||
|
||||
return serializeBigInt(coupon);
|
||||
}
|
||||
|
||||
async listCoupons(userId: bigint) {
|
||||
const list = await this.prisma.benefitCoupon.findMany({
|
||||
where: { userId, status: { in: ['ACTIVE', 'USED_UP'] } },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
});
|
||||
return serializeBigInt(list);
|
||||
}
|
||||
|
||||
async getSummary(userId: bigint) {
|
||||
const coupons = await this.prisma.benefitCoupon.findMany({
|
||||
where: { userId, status: 'ACTIVE' },
|
||||
orderBy: { createdAt: 'asc' },
|
||||
});
|
||||
const summary = calcBenefitSummary(
|
||||
coupons.map((c) => Number(c.balance)),
|
||||
REDEEM_MAX_AMOUNT,
|
||||
);
|
||||
return serializeBigInt(summary);
|
||||
}
|
||||
|
||||
async getLedger(userId: bigint, couponId?: bigint) {
|
||||
const list = await this.prisma.benefitLedger.findMany({
|
||||
where: { userId, ...(couponId ? { couponId } : {}) },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
});
|
||||
return serializeBigInt(list);
|
||||
}
|
||||
|
||||
async getCoupon(userId: bigint, couponId: bigint) {
|
||||
const coupon = await this.prisma.benefitCoupon.findFirst({
|
||||
where: { id: couponId, userId },
|
||||
});
|
||||
if (!coupon) return null;
|
||||
const ledgers = await this.prisma.benefitLedger.findMany({
|
||||
where: { couponId },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
});
|
||||
return serializeBigInt({ coupon, ledgers });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import { Controller, Get, Param, Query } from '@nestjs/common';
|
||||
import { CatalogService } from './catalog.service';
|
||||
|
||||
@Controller('catalog')
|
||||
export class CatalogController {
|
||||
constructor(private readonly catalogService: CatalogService) {}
|
||||
|
||||
@Get('cities')
|
||||
cities() {
|
||||
return this.catalogService.listCities();
|
||||
}
|
||||
|
||||
@Get('products')
|
||||
products(@Query('aromaType') aromaType?: string) {
|
||||
return this.catalogService.listProducts(aromaType);
|
||||
}
|
||||
|
||||
@Get('products/:id')
|
||||
product(@Param('id') id: string) {
|
||||
return this.catalogService.getProduct(BigInt(id));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { CatalogController } from './catalog.controller';
|
||||
import { CatalogService } from './catalog.service';
|
||||
|
||||
@Module({
|
||||
controllers: [CatalogController],
|
||||
providers: [CatalogService],
|
||||
exports: [CatalogService],
|
||||
})
|
||||
export class CatalogModule {}
|
||||
@@ -0,0 +1,41 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { PrismaService } from '../../common/prisma/prisma.module';
|
||||
import { serializeBigInt } from '../../common/decorators/current-user.decorator';
|
||||
|
||||
@Injectable()
|
||||
export class CatalogService {
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
async listCities() {
|
||||
const cities = await this.prisma.city.findMany({
|
||||
where: { status: 'ACTIVE' },
|
||||
include: { partner: { select: { companyName: true } } },
|
||||
});
|
||||
return serializeBigInt(cities);
|
||||
}
|
||||
|
||||
async listProducts(aromaType?: string) {
|
||||
const products = await this.prisma.product.findMany({
|
||||
where: { status: 'ON_SALE', ...(aromaType ? { aromaType: aromaType as never } : {}) },
|
||||
orderBy: { sortOrder: 'asc' },
|
||||
});
|
||||
return serializeBigInt(
|
||||
products.map((p) => ({
|
||||
...p,
|
||||
benefitAmount: p.benefitAmount ?? p.price,
|
||||
price: Number(p.price),
|
||||
benefitDisplay: Number(p.benefitAmount ?? p.price),
|
||||
})),
|
||||
);
|
||||
}
|
||||
|
||||
async getProduct(id: bigint) {
|
||||
const product = await this.prisma.product.findUnique({ where: { id } });
|
||||
if (!product) return null;
|
||||
return serializeBigInt({
|
||||
...product,
|
||||
benefitAmount: product.benefitAmount ?? product.price,
|
||||
price: Number(product.price),
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { Controller, Get } from '@nestjs/common';
|
||||
|
||||
@Controller('health')
|
||||
export class HealthController {
|
||||
@Get()
|
||||
check() {
|
||||
return { status: 'ok', service: 'dukang-api', version: 'prev1' };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { HealthController } from './health.controller';
|
||||
|
||||
@Module({ controllers: [HealthController] })
|
||||
export class HealthModule {}
|
||||
@@ -0,0 +1,89 @@
|
||||
import { Body, Controller, Get, Post, UseGuards } from '@nestjs/common';
|
||||
import { AuthService } from './auth.service';
|
||||
import { LoginSmsDto, SendSmsDto } from './dto/auth.dto';
|
||||
import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard';
|
||||
import { CurrentUser } from '../../common/decorators/current-user.decorator';
|
||||
import { AuthUser } from '../../common/guards/jwt-auth.guard';
|
||||
import { ClientApp } from '@dukang/shared-types';
|
||||
|
||||
@Controller()
|
||||
export class UserAuthController {
|
||||
constructor(private readonly authService: AuthService) {}
|
||||
|
||||
@Post('auth/sms/send')
|
||||
sendSms(@Body() dto: SendSmsDto) {
|
||||
return this.authService.sendSms(dto.phone, dto.scene);
|
||||
}
|
||||
|
||||
@Post('auth/login/sms')
|
||||
login(@Body() dto: LoginSmsDto) {
|
||||
return this.authService.loginUser(dto.phone, dto.code, ClientApp.USER_H5);
|
||||
}
|
||||
|
||||
@Post('auth/login/wechat')
|
||||
wechatLogin() {
|
||||
return this.authService.wechatDisabled();
|
||||
}
|
||||
|
||||
@Post('auth/wechat/bind-phone')
|
||||
bindPhone() {
|
||||
return this.authService.wechatDisabled();
|
||||
}
|
||||
|
||||
@Get('auth/me')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
me(@CurrentUser() user: AuthUser) {
|
||||
return this.authService.getMe(user.actorType, user.actorId);
|
||||
}
|
||||
}
|
||||
|
||||
@Controller('shop/auth')
|
||||
export class ShopAuthController {
|
||||
constructor(private readonly authService: AuthService) {}
|
||||
|
||||
@Post('sms/send')
|
||||
sendSms(@Body() dto: SendSmsDto) {
|
||||
return this.authService.sendSms(dto.phone, dto.scene);
|
||||
}
|
||||
|
||||
@Post('login/sms')
|
||||
login(@Body() dto: LoginSmsDto) {
|
||||
return this.authService.loginStore(dto.phone, dto.code, ClientApp.SHOP_H5);
|
||||
}
|
||||
|
||||
@Post('login/wechat')
|
||||
wechatLogin() {
|
||||
return this.authService.wechatDisabled();
|
||||
}
|
||||
}
|
||||
|
||||
@Controller('partner/auth')
|
||||
export class PartnerAuthController {
|
||||
constructor(private readonly authService: AuthService) {}
|
||||
|
||||
@Post('sms/send')
|
||||
sendSms(@Body() dto: SendSmsDto) {
|
||||
return this.authService.sendSms(dto.phone, dto.scene);
|
||||
}
|
||||
|
||||
@Post('login/sms')
|
||||
login(@Body() dto: LoginSmsDto) {
|
||||
return this.authService.loginPartner(dto.phone, dto.code, ClientApp.PARTNER_H5);
|
||||
}
|
||||
|
||||
@Post('login/wechat')
|
||||
wechatLogin() {
|
||||
return this.authService.wechatDisabled();
|
||||
}
|
||||
}
|
||||
|
||||
@Controller('user')
|
||||
export class UserProfileController {
|
||||
constructor(private readonly authService: AuthService) {}
|
||||
|
||||
@Get('profile')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
profile(@CurrentUser() user: AuthUser) {
|
||||
return this.authService.getMe(user.actorType, user.actorId);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
import {
|
||||
BadRequestException,
|
||||
Inject,
|
||||
Injectable,
|
||||
NotImplementedException,
|
||||
} from '@nestjs/common';
|
||||
import { JwtService } from '@nestjs/jwt';
|
||||
import { ClientApp, SmsScene } from '@dukang/shared-types';
|
||||
import { generateUserNo } from '@dukang/domain';
|
||||
import { PrismaService } from '../../common/prisma/prisma.module';
|
||||
import { SMS_PROVIDER } from '../../integrations/integrations.constants';
|
||||
import { ISmsProvider } from '../../integrations/sms/sms.interface';
|
||||
import { serializeBigInt } from '../../common/decorators/current-user.decorator';
|
||||
|
||||
@Injectable()
|
||||
export class AuthService {
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly jwtService: JwtService,
|
||||
@Inject(SMS_PROVIDER) private readonly smsProvider: ISmsProvider,
|
||||
) {}
|
||||
|
||||
async sendSms(phone: string, scene: string) {
|
||||
await this.smsProvider.send(phone, scene);
|
||||
return { sent: true };
|
||||
}
|
||||
|
||||
async loginUser(phone: string, code: string, clientApp: ClientApp) {
|
||||
await this.smsProvider.verify(phone, code, SmsScene.USER_LOGIN);
|
||||
let user = await this.prisma.user.findUnique({ where: { phone } });
|
||||
if (!user) {
|
||||
user = await this.prisma.user.create({
|
||||
data: {
|
||||
phone,
|
||||
userNo: generateUserNo(),
|
||||
nickname: `用户${phone.slice(-4)}`,
|
||||
},
|
||||
});
|
||||
await this.prisma.userCityPreference.create({
|
||||
data: { userId: user.id, selectedCityCode: '410100', selectedDistrict: '郑州市' },
|
||||
});
|
||||
}
|
||||
return this.issueToken('USER', user.id, clientApp, {
|
||||
id: user.id.toString(),
|
||||
userNo: user.userNo,
|
||||
phone: user.phone.replace(/(\d{3})\d{4}(\d{4})/, '$1****$2'),
|
||||
nickname: user.nickname,
|
||||
hasWechat: !!user.wxOpenId,
|
||||
});
|
||||
}
|
||||
|
||||
async loginStore(phone: string, code: string, clientApp: ClientApp) {
|
||||
await this.smsProvider.verify(phone, code, SmsScene.STORE_LOGIN);
|
||||
const account = await this.prisma.storeAccount.findUnique({
|
||||
where: { phone },
|
||||
include: { store: true },
|
||||
});
|
||||
if (!account) throw new BadRequestException('门店账号不存在');
|
||||
await this.prisma.storeAccount.update({
|
||||
where: { id: account.id },
|
||||
data: { lastLoginAt: new Date() },
|
||||
});
|
||||
return this.issueToken('STORE', account.id, clientApp, undefined, {
|
||||
id: account.id.toString(),
|
||||
storeId: account.storeId.toString(),
|
||||
name: account.name,
|
||||
phone: account.phone,
|
||||
storeName: account.store.name,
|
||||
});
|
||||
}
|
||||
|
||||
async loginPartner(phone: string, code: string, clientApp: ClientApp) {
|
||||
await this.smsProvider.verify(phone, code, SmsScene.PARTNER_LOGIN);
|
||||
const account = await this.prisma.partnerAccount.findUnique({
|
||||
where: { phone },
|
||||
include: { partner: true },
|
||||
});
|
||||
if (!account) throw new BadRequestException('合伙人账号不存在');
|
||||
await this.prisma.partnerAccount.update({
|
||||
where: { id: account.id },
|
||||
data: { lastLoginAt: new Date() },
|
||||
});
|
||||
return this.issueToken('PARTNER', account.id, clientApp, undefined, undefined, {
|
||||
id: account.id.toString(),
|
||||
partnerId: account.partnerId.toString(),
|
||||
name: account.name,
|
||||
phone: account.phone,
|
||||
isPrimary: account.isPrimary === 1,
|
||||
companyName: account.partner.companyName,
|
||||
});
|
||||
}
|
||||
|
||||
async getMe(actorType: string, actorId: bigint) {
|
||||
if (actorType === 'USER') {
|
||||
const user = await this.prisma.user.findUnique({ where: { id: actorId } });
|
||||
return serializeBigInt(user);
|
||||
}
|
||||
if (actorType === 'STORE') {
|
||||
const account = await this.prisma.storeAccount.findUnique({
|
||||
where: { id: actorId },
|
||||
include: { store: true },
|
||||
});
|
||||
return serializeBigInt(account);
|
||||
}
|
||||
if (actorType === 'PARTNER') {
|
||||
const account = await this.prisma.partnerAccount.findUnique({
|
||||
where: { id: actorId },
|
||||
include: { partner: true },
|
||||
});
|
||||
return serializeBigInt(account);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
wechatDisabled() {
|
||||
throw new NotImplementedException('FEATURE_DISABLED');
|
||||
}
|
||||
|
||||
private issueToken(
|
||||
actorType: string,
|
||||
actorId: bigint,
|
||||
clientApp: ClientApp,
|
||||
user?: Record<string, unknown>,
|
||||
store?: Record<string, unknown>,
|
||||
partner?: Record<string, unknown>,
|
||||
) {
|
||||
const payload = {
|
||||
sub: actorId.toString(),
|
||||
actorType,
|
||||
actorId: actorId.toString(),
|
||||
clientApp,
|
||||
};
|
||||
const accessToken = this.jwtService.sign(payload);
|
||||
const refreshToken = this.jwtService.sign(payload, { expiresIn: '30d' });
|
||||
return {
|
||||
accessToken,
|
||||
refreshToken,
|
||||
actorType,
|
||||
actorId: actorId.toString(),
|
||||
user,
|
||||
store,
|
||||
partner,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import { IsNotEmpty, IsString } from 'class-validator';
|
||||
|
||||
export class SendSmsDto {
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
phone: string;
|
||||
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
scene: string;
|
||||
}
|
||||
|
||||
export class LoginSmsDto {
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
phone: string;
|
||||
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
code: string;
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { JwtModule } from '@nestjs/jwt';
|
||||
import { IntegrationsModule } from '../../integrations/integrations.module';
|
||||
import { AuthService } from './auth.service';
|
||||
import {
|
||||
PartnerAuthController,
|
||||
ShopAuthController,
|
||||
UserAuthController,
|
||||
UserProfileController,
|
||||
} from './auth.controller';
|
||||
import { UserAddressController } from './user-address.controller';
|
||||
import { UserAddressService } from './user-address.service';
|
||||
import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
IntegrationsModule,
|
||||
JwtModule.register({
|
||||
secret: process.env.JWT_SECRET || 'dukang-prev1-dev-secret',
|
||||
signOptions: { expiresIn: process.env.JWT_EXPIRES_IN || '7d' },
|
||||
}),
|
||||
],
|
||||
controllers: [
|
||||
UserAuthController,
|
||||
ShopAuthController,
|
||||
PartnerAuthController,
|
||||
UserProfileController,
|
||||
UserAddressController,
|
||||
],
|
||||
providers: [AuthService, UserAddressService, JwtAuthGuard],
|
||||
exports: [AuthService, JwtModule, JwtAuthGuard],
|
||||
})
|
||||
export class IamModule {}
|
||||
@@ -0,0 +1,34 @@
|
||||
import { Body, Controller, Delete, Get, Param, Post, Put, UseGuards } from '@nestjs/common';
|
||||
import { UserAddressService } from './user-address.service';
|
||||
import { JwtAuthGuard, AuthUser } from '../../common/guards/jwt-auth.guard';
|
||||
import { CurrentUser } from '../../common/decorators/current-user.decorator';
|
||||
|
||||
@Controller('user/addresses')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
export class UserAddressController {
|
||||
constructor(private readonly addressService: UserAddressService) {}
|
||||
|
||||
@Get()
|
||||
list(@CurrentUser() user: AuthUser) {
|
||||
return this.addressService.list(user.actorId);
|
||||
}
|
||||
|
||||
@Post()
|
||||
create(@CurrentUser() user: AuthUser, @Body() body: Record<string, unknown>) {
|
||||
return this.addressService.create(user.actorId, body);
|
||||
}
|
||||
|
||||
@Put(':id')
|
||||
update(
|
||||
@CurrentUser() user: AuthUser,
|
||||
@Param('id') id: string,
|
||||
@Body() body: Record<string, unknown>,
|
||||
) {
|
||||
return this.addressService.update(user.actorId, BigInt(id), body);
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
remove(@CurrentUser() user: AuthUser, @Param('id') id: string) {
|
||||
return this.addressService.remove(user.actorId, BigInt(id));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
import { Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { PrismaService } from '../../common/prisma/prisma.module';
|
||||
import { serializeBigInt } from '../../common/decorators/current-user.decorator';
|
||||
|
||||
@Injectable()
|
||||
export class UserAddressService {
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
async list(userId: bigint) {
|
||||
const list = await this.prisma.userAddress.findMany({
|
||||
where: { userId },
|
||||
orderBy: [{ isDefault: 'desc' }, { updatedAt: 'desc' }],
|
||||
});
|
||||
return serializeBigInt(list);
|
||||
}
|
||||
|
||||
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,
|
||||
},
|
||||
});
|
||||
return serializeBigInt(address);
|
||||
}
|
||||
|
||||
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,
|
||||
},
|
||||
});
|
||||
return serializeBigInt(address);
|
||||
}
|
||||
|
||||
async remove(userId: bigint, id: bigint) {
|
||||
const existing = await this.prisma.userAddress.findFirst({ where: { id, userId } });
|
||||
if (!existing) throw new NotFoundException('地址不存在');
|
||||
await this.prisma.userAddress.delete({ where: { id } });
|
||||
return { deleted: true };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import { Body, Controller, Get, Param, Post, Query, UseGuards } from '@nestjs/common';
|
||||
import { RedeemService } from './redeem.service';
|
||||
import { JwtAuthGuard, AuthUser } from '../../common/guards/jwt-auth.guard';
|
||||
import { CurrentUser } from '../../common/decorators/current-user.decorator';
|
||||
|
||||
@Controller('redeem')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
export class UserRedeemController {
|
||||
constructor(private readonly redeemService: RedeemService) {}
|
||||
|
||||
@Post('tokens')
|
||||
createToken(@CurrentUser() user: AuthUser, @Body() body: Record<string, unknown>) {
|
||||
return this.redeemService.createToken(user.actorId, body as never);
|
||||
}
|
||||
|
||||
@Get('tokens/:token')
|
||||
getToken(@Param('token') token: string) {
|
||||
return this.redeemService.getToken(token);
|
||||
}
|
||||
|
||||
@Post('ratings')
|
||||
rating(@CurrentUser() user: AuthUser, @Body() body: Record<string, unknown>) {
|
||||
return this.redeemService.submitRating(user.actorId, body as never);
|
||||
}
|
||||
}
|
||||
|
||||
@Controller('shop/redeem')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
export class ShopRedeemController {
|
||||
constructor(private readonly redeemService: RedeemService) {}
|
||||
|
||||
@Post('confirm')
|
||||
confirm(@CurrentUser() user: AuthUser, @Body() body: { token: string }) {
|
||||
return this.redeemService.confirmRedeem(user.actorId, body);
|
||||
}
|
||||
|
||||
@Get('records')
|
||||
records(
|
||||
@CurrentUser() user: AuthUser,
|
||||
@Query('page') page = '1',
|
||||
@Query('pageSize') pageSize = '20',
|
||||
) {
|
||||
return this.redeemService.listShopRecords(user.actorId, Number(page), Number(pageSize));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import { Module, forwardRef } from '@nestjs/common';
|
||||
import { IamModule } from '../iam/iam.module';
|
||||
import { SettlementModule } from '../settlement/settlement.module';
|
||||
import { RedeemService } from './redeem.service';
|
||||
import { ShopRedeemController, UserRedeemController } from './redeem.controller';
|
||||
|
||||
@Module({
|
||||
imports: [IamModule, forwardRef(() => SettlementModule)],
|
||||
controllers: [UserRedeemController, ShopRedeemController],
|
||||
providers: [RedeemService],
|
||||
exports: [RedeemService],
|
||||
})
|
||||
export class RedeemModule {}
|
||||
@@ -0,0 +1,251 @@
|
||||
import {
|
||||
BadRequestException,
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
import { randomBytes } from 'crypto';
|
||||
import {
|
||||
calcRedeemSettleAmount,
|
||||
generateRedeemNo,
|
||||
validateRedeemAmount,
|
||||
allocateBenefitCoupons,
|
||||
} from '@dukang/domain';
|
||||
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 { SettlementService } from '../settlement/settlement.service';
|
||||
|
||||
@Injectable()
|
||||
export class RedeemService {
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly redis: RedisService,
|
||||
private readonly settlementService: SettlementService,
|
||||
) {}
|
||||
|
||||
async createToken(userId: bigint, body: { couponId?: string; amount: number; storeId?: string }) {
|
||||
let allocations: Array<{ couponId: string; amount: number }>;
|
||||
|
||||
if (body.couponId) {
|
||||
const coupon = await this.prisma.benefitCoupon.findFirst({
|
||||
where: { id: BigInt(body.couponId), userId, status: 'ACTIVE' },
|
||||
});
|
||||
if (!coupon) throw new NotFoundException('券不存在');
|
||||
const balance = Number(coupon.balance);
|
||||
const check = validateRedeemAmount(balance, body.amount);
|
||||
if (!check.ok) throw new BadRequestException(check.message);
|
||||
allocations = [{ couponId: coupon.id.toString(), amount: body.amount }];
|
||||
} else {
|
||||
const coupons = await this.prisma.benefitCoupon.findMany({
|
||||
where: { userId, status: 'ACTIVE' },
|
||||
orderBy: { createdAt: 'asc' },
|
||||
});
|
||||
const result = allocateBenefitCoupons(
|
||||
coupons.map((c) => ({
|
||||
id: c.id.toString(),
|
||||
balance: Number(c.balance),
|
||||
createdAt: c.createdAt.getTime(),
|
||||
})),
|
||||
body.amount,
|
||||
);
|
||||
if (!result.ok) throw new BadRequestException(result.message);
|
||||
allocations = result.allocations;
|
||||
}
|
||||
|
||||
const primaryCouponId = BigInt(allocations[0].couponId);
|
||||
const token = randomBytes(16).toString('hex');
|
||||
const expireAt = new Date(Date.now() + REDEEM_TOKEN_TTL_SECONDS * 1000);
|
||||
|
||||
await this.prisma.redeemToken.create({
|
||||
data: {
|
||||
token,
|
||||
userId,
|
||||
couponId: primaryCouponId,
|
||||
storeId: body.storeId ? BigInt(body.storeId) : null,
|
||||
amount: body.amount,
|
||||
expireAt,
|
||||
},
|
||||
});
|
||||
|
||||
await this.redis.setJson(
|
||||
`redeem:token:${token}`,
|
||||
{
|
||||
userId: userId.toString(),
|
||||
couponId: primaryCouponId.toString(),
|
||||
amount: body.amount,
|
||||
storeId: body.storeId ?? null,
|
||||
allocations,
|
||||
},
|
||||
REDEEM_TOKEN_TTL_SECONDS,
|
||||
);
|
||||
|
||||
return { token, expireAt, amount: body.amount };
|
||||
}
|
||||
|
||||
async getToken(token: string) {
|
||||
const cached = await this.redis.getJson<Record<string, unknown>>(`redeem:token:${token}`);
|
||||
if (!cached) throw new NotFoundException('核销码已过期');
|
||||
return cached;
|
||||
}
|
||||
|
||||
async confirmRedeem(storeAccountId: bigint, body: { token: string }) {
|
||||
const account = await this.prisma.storeAccount.findUniqueOrThrow({
|
||||
where: { id: storeAccountId },
|
||||
include: { store: true },
|
||||
});
|
||||
if (account.store.status !== 'OPEN') {
|
||||
throw new BadRequestException('门店未营业');
|
||||
}
|
||||
|
||||
const cached = await this.redis.getJson<{
|
||||
userId: string;
|
||||
couponId?: string;
|
||||
amount: number;
|
||||
allocations?: Array<{ couponId: string; amount: number }>;
|
||||
}>(`redeem:token:${body.token}`);
|
||||
if (!cached) throw new BadRequestException('核销码无效或已过期');
|
||||
|
||||
const allocations =
|
||||
cached.allocations ??
|
||||
(cached.couponId
|
||||
? [{ couponId: cached.couponId, amount: cached.amount }]
|
||||
: []);
|
||||
if (allocations.length === 0) {
|
||||
throw new BadRequestException('核销码数据异常');
|
||||
}
|
||||
|
||||
const allocSum = allocations.reduce((sum, item) => sum + item.amount, 0);
|
||||
if (Math.abs(allocSum - cached.amount) > 0.001) {
|
||||
throw new BadRequestException('核销码数据异常');
|
||||
}
|
||||
|
||||
for (const alloc of allocations) {
|
||||
const coupon = await this.prisma.benefitCoupon.findUnique({
|
||||
where: { id: BigInt(alloc.couponId) },
|
||||
});
|
||||
if (!coupon) throw new BadRequestException('券不存在');
|
||||
const check = validateRedeemAmount(Number(coupon.balance), alloc.amount);
|
||||
if (!check.ok) throw new BadRequestException(check.message);
|
||||
}
|
||||
|
||||
const amount = Number(cached.amount);
|
||||
const cityRule = await this.prisma.cityCommissionRule.findFirst({
|
||||
where: { city: { stores: { some: { id: account.storeId } } } },
|
||||
});
|
||||
const settlementRate = cityRule ? Number(cityRule.storeSettlementRate) : 0.6;
|
||||
const settleAmount = calcRedeemSettleAmount(amount, settlementRate);
|
||||
|
||||
const record = await this.prisma.$transaction(async (tx) => {
|
||||
for (const alloc of allocations) {
|
||||
const coupon = await tx.benefitCoupon.findUniqueOrThrow({
|
||||
where: { id: BigInt(alloc.couponId) },
|
||||
});
|
||||
const allocAmount = alloc.amount;
|
||||
const updated = await tx.benefitCoupon.updateMany({
|
||||
where: { id: coupon.id, version: coupon.version, balance: { gte: allocAmount } },
|
||||
data: {
|
||||
usedAmount: { increment: allocAmount },
|
||||
balance: { decrement: allocAmount },
|
||||
version: { increment: 1 },
|
||||
status: Number(coupon.balance) - allocAmount <= 0 ? 'USED_UP' : 'ACTIVE',
|
||||
},
|
||||
});
|
||||
if (updated.count === 0) throw new BadRequestException('核销失败,请重试');
|
||||
|
||||
const newBalance = Number(coupon.balance) - allocAmount;
|
||||
await tx.benefitLedger.create({
|
||||
data: {
|
||||
userId: coupon.userId,
|
||||
couponId: coupon.id,
|
||||
type: 'REDEEM',
|
||||
amount: -allocAmount,
|
||||
balanceAfter: newBalance,
|
||||
refType: 'STORE',
|
||||
refId: account.storeId,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const redeemRecord = await tx.redeemRecord.create({
|
||||
data: {
|
||||
redeemNo: generateRedeemNo(),
|
||||
userId: BigInt(cached.userId),
|
||||
couponId: BigInt(allocations[0].couponId),
|
||||
storeId: account.storeId,
|
||||
amount,
|
||||
settleAmount,
|
||||
},
|
||||
});
|
||||
|
||||
await tx.redeemToken.updateMany({
|
||||
where: { token: body.token },
|
||||
data: { status: 'USED', usedAt: new Date(), storeId: account.storeId },
|
||||
});
|
||||
|
||||
return redeemRecord;
|
||||
});
|
||||
|
||||
await this.settlementService.createStorePayout(record.id, account.storeId, amount, settleAmount, settlementRate);
|
||||
await this.redis.del(`redeem:token:${body.token}`);
|
||||
|
||||
return serializeBigInt(record);
|
||||
}
|
||||
|
||||
async listShopRecords(storeAccountId: bigint, page = 1, pageSize = 20) {
|
||||
const account = await this.prisma.storeAccount.findUniqueOrThrow({
|
||||
where: { id: storeAccountId },
|
||||
});
|
||||
const [list, total] = await Promise.all([
|
||||
this.prisma.redeemRecord.findMany({
|
||||
where: { storeId: account.storeId },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
skip: (page - 1) * pageSize,
|
||||
take: pageSize,
|
||||
}),
|
||||
this.prisma.redeemRecord.count({ where: { storeId: account.storeId } }),
|
||||
]);
|
||||
return { list: serializeBigInt(list), total, page, pageSize };
|
||||
}
|
||||
|
||||
async getShopDashboard(storeAccountId: bigint) {
|
||||
const account = await this.prisma.storeAccount.findUniqueOrThrow({
|
||||
where: { id: storeAccountId },
|
||||
include: { store: true },
|
||||
});
|
||||
const start = new Date();
|
||||
start.setHours(0, 0, 0, 0);
|
||||
const records = await this.prisma.redeemRecord.findMany({
|
||||
where: { storeId: account.storeId, createdAt: { gte: start } },
|
||||
});
|
||||
const todayCount = records.length;
|
||||
const todayAmount = records.reduce((sum, r) => sum + Number(r.amount), 0);
|
||||
const recent = await this.prisma.redeemRecord.findMany({
|
||||
where: { storeId: account.storeId },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
take: 3,
|
||||
});
|
||||
return serializeBigInt({
|
||||
store: account.store,
|
||||
todayCount,
|
||||
todayAmount,
|
||||
recentRecords: recent,
|
||||
});
|
||||
}
|
||||
|
||||
async submitRating(userId: bigint, body: { redeemRecordId: string; serviceScore: number; envScore: number }) {
|
||||
const record = await this.prisma.redeemRecord.findFirst({
|
||||
where: { id: BigInt(body.redeemRecordId), userId },
|
||||
});
|
||||
if (!record) throw new NotFoundException('核销记录不存在');
|
||||
const rating = await this.prisma.storeRating.create({
|
||||
data: {
|
||||
redeemRecordId: record.id,
|
||||
storeId: record.storeId,
|
||||
serviceScore: body.serviceScore,
|
||||
envScore: body.envScore,
|
||||
},
|
||||
});
|
||||
return serializeBigInt(rating);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import { Controller, Get, UseGuards } from '@nestjs/common';
|
||||
import { SettlementService } from './settlement.service';
|
||||
import { JwtAuthGuard, AuthUser } from '../../common/guards/jwt-auth.guard';
|
||||
import { CurrentUser } from '../../common/decorators/current-user.decorator';
|
||||
import { PrismaService } from '../../common/prisma/prisma.module';
|
||||
|
||||
@Controller('partner/settlement')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
export class SettlementController {
|
||||
constructor(private readonly settlementService: SettlementService) {}
|
||||
|
||||
@Get('bills')
|
||||
bills(@CurrentUser() user: AuthUser) {
|
||||
return this.settlementService.listPartnerBills(user.actorId);
|
||||
}
|
||||
}
|
||||
|
||||
@Controller('partner/me')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
export class PartnerMeController {
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
@Get()
|
||||
async me(@CurrentUser() user: AuthUser) {
|
||||
const account = await this.prisma.partnerAccount.findUniqueOrThrow({
|
||||
where: { id: user.actorId },
|
||||
include: { partner: true },
|
||||
});
|
||||
return {
|
||||
id: account.id.toString(),
|
||||
name: account.name,
|
||||
phone: account.phone,
|
||||
isPrimary: account.isPrimary === 1,
|
||||
companyName: account.partner.companyName,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { IamModule } from '../iam/iam.module';
|
||||
import { SettlementService } from './settlement.service';
|
||||
import { PartnerMeController, SettlementController } from './settlement.controller';
|
||||
|
||||
@Module({
|
||||
imports: [IamModule],
|
||||
controllers: [SettlementController, PartnerMeController],
|
||||
providers: [SettlementService],
|
||||
exports: [SettlementService],
|
||||
})
|
||||
export class SettlementModule {}
|
||||
@@ -0,0 +1,42 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { PrismaService } from '../../common/prisma/prisma.module';
|
||||
import { serializeBigInt } from '../../common/decorators/current-user.decorator';
|
||||
|
||||
@Injectable()
|
||||
export class SettlementService {
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
async createStorePayout(
|
||||
redeemRecordId: bigint,
|
||||
storeId: bigint,
|
||||
redeemAmount: number,
|
||||
payoutAmount: number,
|
||||
settlementRate: number,
|
||||
) {
|
||||
const expectedPayAt = new Date();
|
||||
expectedPayAt.setDate(expectedPayAt.getDate() + 1);
|
||||
const payout = await this.prisma.storePayout.create({
|
||||
data: {
|
||||
redeemRecordId,
|
||||
storeId,
|
||||
redeemAmount,
|
||||
payoutAmount,
|
||||
settlementRate,
|
||||
status: 'PENDING',
|
||||
expectedPayAt,
|
||||
},
|
||||
});
|
||||
return serializeBigInt(payout);
|
||||
}
|
||||
|
||||
async listPartnerBills(partnerAccountId: bigint) {
|
||||
const account = await this.prisma.partnerAccount.findUniqueOrThrow({
|
||||
where: { id: partnerAccountId },
|
||||
});
|
||||
const bills = await this.prisma.partnerBill.findMany({
|
||||
where: { partnerId: account.partnerId },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
});
|
||||
return serializeBigInt(bills);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
import { Body, Controller, Get, Param, Post, Put, Query, UseGuards } from '@nestjs/common';
|
||||
import { StoreService } from './store.service';
|
||||
import { RedeemService } from '../redeem/redeem.service';
|
||||
import { JwtAuthGuard, AuthUser } from '../../common/guards/jwt-auth.guard';
|
||||
import { CurrentUser } from '../../common/decorators/current-user.decorator';
|
||||
|
||||
@Controller('stores')
|
||||
export class PublicStoreController {
|
||||
constructor(private readonly storeService: StoreService) {}
|
||||
|
||||
@Get()
|
||||
list(@Query('cityCode') cityCode?: string) {
|
||||
return this.storeService.listOpenStores(cityCode);
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
detail(@Param('id') id: string) {
|
||||
return this.storeService.getStore(BigInt(id));
|
||||
}
|
||||
}
|
||||
|
||||
@Controller('partner/stores')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
export class PartnerStoreController {
|
||||
constructor(private readonly storeService: StoreService) {}
|
||||
|
||||
@Get()
|
||||
list(@CurrentUser() user: AuthUser) {
|
||||
return this.storeService.partnerListStores(user.actorId);
|
||||
}
|
||||
|
||||
@Post()
|
||||
create(@CurrentUser() user: AuthUser, @Body() body: Record<string, unknown>) {
|
||||
return this.storeService.createStore(user.actorId, body);
|
||||
}
|
||||
}
|
||||
|
||||
@Controller('partner/dashboard')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
export class PartnerDashboardController {
|
||||
constructor(private readonly storeService: StoreService) {}
|
||||
|
||||
@Get()
|
||||
dashboard(@CurrentUser() user: AuthUser) {
|
||||
return this.storeService.partnerDashboard(user.actorId);
|
||||
}
|
||||
}
|
||||
|
||||
@Controller('shop/store')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
export class ShopStoreController {
|
||||
constructor(private readonly storeService: StoreService) {}
|
||||
|
||||
@Get()
|
||||
info(@CurrentUser() user: AuthUser) {
|
||||
return this.storeService.getShopStore(user.actorId);
|
||||
}
|
||||
|
||||
@Put('status')
|
||||
status(@CurrentUser() user: AuthUser, @Body() body: { status: 'OPEN' | 'PAUSED' }) {
|
||||
return this.storeService.updateShopStatus(user.actorId, body.status);
|
||||
}
|
||||
}
|
||||
|
||||
@Controller('shop/dashboard')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
export class ShopDashboardController {
|
||||
constructor(private readonly redeemService: RedeemService) {}
|
||||
|
||||
@Get()
|
||||
async dashboard(@CurrentUser() user: AuthUser) {
|
||||
return this.redeemService.getShopDashboard(user.actorId);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import { Module, forwardRef } from '@nestjs/common';
|
||||
import { IamModule } from '../iam/iam.module';
|
||||
import { RedeemModule } from '../redeem/redeem.module';
|
||||
import { StoreService } from './store.service';
|
||||
import {
|
||||
PartnerDashboardController,
|
||||
PartnerStoreController,
|
||||
PublicStoreController,
|
||||
ShopDashboardController,
|
||||
ShopStoreController,
|
||||
} from './store.controller';
|
||||
|
||||
@Module({
|
||||
imports: [IamModule, forwardRef(() => RedeemModule)],
|
||||
controllers: [
|
||||
PublicStoreController,
|
||||
PartnerStoreController,
|
||||
PartnerDashboardController,
|
||||
ShopStoreController,
|
||||
ShopDashboardController,
|
||||
],
|
||||
providers: [StoreService],
|
||||
exports: [StoreService],
|
||||
})
|
||||
export class StoreModule {}
|
||||
@@ -0,0 +1,133 @@
|
||||
import {
|
||||
BadRequestException,
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
import { loadAppConfig } from '@dukang/shared-types';
|
||||
import { PrismaService } from '../../common/prisma/prisma.module';
|
||||
import { serializeBigInt } from '../../common/decorators/current-user.decorator';
|
||||
|
||||
@Injectable()
|
||||
export class StoreService {
|
||||
private readonly config = loadAppConfig();
|
||||
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
async listOpenStores(cityCode?: string) {
|
||||
const where: Record<string, unknown> = { status: 'OPEN' };
|
||||
if (cityCode) {
|
||||
const city = await this.prisma.city.findFirst({ where: { code: cityCode } });
|
||||
if (city) where.cityId = city.id;
|
||||
}
|
||||
const stores = await this.prisma.store.findMany({
|
||||
where: where as never,
|
||||
include: { category: true },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
});
|
||||
return serializeBigInt(stores);
|
||||
}
|
||||
|
||||
async getStore(id: bigint) {
|
||||
const store = await this.prisma.store.findFirst({
|
||||
where: { id, status: 'OPEN' },
|
||||
include: { category: true, media: true },
|
||||
});
|
||||
if (!store) throw new NotFoundException('门店不存在');
|
||||
return serializeBigInt(store);
|
||||
}
|
||||
|
||||
async partnerListStores(partnerAccountId: bigint) {
|
||||
const account = await this.getPartnerAccount(partnerAccountId);
|
||||
const stores = await this.prisma.store.findMany({
|
||||
where: { partnerId: account.partnerId },
|
||||
include: { category: true, audits: { orderBy: { submittedAt: 'desc' }, take: 1 } },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
});
|
||||
return serializeBigInt(stores);
|
||||
}
|
||||
|
||||
async createStore(partnerAccountId: bigint, body: Record<string, unknown>) {
|
||||
const account = await this.getPartnerAccount(partnerAccountId);
|
||||
const city = await this.prisma.city.findFirst({ where: { partnerId: account.partnerId } });
|
||||
if (!city) throw new BadRequestException('合伙人未绑定开城');
|
||||
|
||||
const store = await this.prisma.store.create({
|
||||
data: {
|
||||
cityId: city.id,
|
||||
partnerId: account.partnerId,
|
||||
categoryId: body.categoryId ? BigInt(String(body.categoryId)) : null,
|
||||
name: String(body.name),
|
||||
phone: String(body.phone),
|
||||
province: String(body.province ?? '河南省'),
|
||||
cityName: String(body.city ?? '郑州市'),
|
||||
district: String(body.district ?? ''),
|
||||
address: String(body.address),
|
||||
intro: body.intro ? String(body.intro) : null,
|
||||
coverUrl: body.coverUrl ? String(body.coverUrl) : null,
|
||||
bankAccountName: body.bankAccountName ? String(body.bankAccountName) : null,
|
||||
bankAccountNo: body.bankAccountNo ? String(body.bankAccountNo) : null,
|
||||
bankBranch: body.bankBranch ? String(body.bankBranch) : null,
|
||||
openTime: body.openTime ? String(body.openTime) : '10:00',
|
||||
closeTime: body.closeTime ? String(body.closeTime) : '22:00',
|
||||
status: this.config.autoApproveStore ? 'OPEN' : 'PAUSED',
|
||||
},
|
||||
});
|
||||
|
||||
const audit = await this.prisma.storeAudit.create({
|
||||
data: {
|
||||
storeId: store.id,
|
||||
auditType: 'NEW',
|
||||
status: this.config.autoApproveStore ? 'APPROVED' : 'PENDING',
|
||||
submitData: body as never,
|
||||
reviewedAt: this.config.autoApproveStore ? new Date() : null,
|
||||
},
|
||||
});
|
||||
|
||||
await this.prisma.storeAccount.create({
|
||||
data: {
|
||||
storeId: store.id,
|
||||
phone: String(body.accountPhone ?? body.phone),
|
||||
name: String(body.accountName ?? body.name),
|
||||
},
|
||||
});
|
||||
|
||||
return serializeBigInt({ store, audit });
|
||||
}
|
||||
|
||||
async getShopStore(storeAccountId: bigint) {
|
||||
const account = await this.prisma.storeAccount.findUniqueOrThrow({
|
||||
where: { id: storeAccountId },
|
||||
include: { store: { include: { category: true } } },
|
||||
});
|
||||
return serializeBigInt(account.store);
|
||||
}
|
||||
|
||||
async updateShopStatus(storeAccountId: bigint, status: 'OPEN' | 'PAUSED') {
|
||||
const account = await this.prisma.storeAccount.findUniqueOrThrow({
|
||||
where: { id: storeAccountId },
|
||||
});
|
||||
const store = await this.prisma.store.update({
|
||||
where: { id: account.storeId },
|
||||
data: { status },
|
||||
});
|
||||
return serializeBigInt(store);
|
||||
}
|
||||
|
||||
async partnerDashboard(partnerAccountId: bigint) {
|
||||
const account = await this.getPartnerAccount(partnerAccountId);
|
||||
const [storeCount, orderCount] = await Promise.all([
|
||||
this.prisma.store.count({ where: { partnerId: account.partnerId } }),
|
||||
this.prisma.order.count({
|
||||
where: { city: { partnerId: account.partnerId } },
|
||||
}),
|
||||
]);
|
||||
return { storeCount, orderCount, companyName: account.partner.companyName };
|
||||
}
|
||||
|
||||
private async getPartnerAccount(partnerAccountId: bigint) {
|
||||
return this.prisma.partnerAccount.findUniqueOrThrow({
|
||||
where: { id: partnerAccountId },
|
||||
include: { partner: true },
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
import { Body, Controller, Get, Param, Post, Put, Query, UseGuards } from '@nestjs/common';
|
||||
import { TradeService } from './trade.service';
|
||||
import { JwtAuthGuard, AuthUser } from '../../common/guards/jwt-auth.guard';
|
||||
import { CurrentUser } from '../../common/decorators/current-user.decorator';
|
||||
|
||||
@Controller('trade/orders')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
export class TradeController {
|
||||
constructor(private readonly tradeService: TradeService) {}
|
||||
|
||||
@Post('preview')
|
||||
preview(@CurrentUser() user: AuthUser, @Body() body: Record<string, unknown>) {
|
||||
return this.tradeService.preview(user.actorId, body as never);
|
||||
}
|
||||
|
||||
@Post()
|
||||
create(@CurrentUser() user: AuthUser, @Body() body: Record<string, unknown>) {
|
||||
return this.tradeService.createOrder(user.actorId, body as never);
|
||||
}
|
||||
|
||||
@Get()
|
||||
list(
|
||||
@CurrentUser() user: AuthUser,
|
||||
@Query('tab') tab = 'all',
|
||||
@Query('page') page = '1',
|
||||
@Query('pageSize') pageSize = '20',
|
||||
) {
|
||||
return this.tradeService.listOrders(user.actorId, tab, Number(page), Number(pageSize));
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
detail(@CurrentUser() user: AuthUser, @Param('id') id: string) {
|
||||
return this.tradeService.getOrder(user.actorId, BigInt(id));
|
||||
}
|
||||
|
||||
@Post(':id/pay')
|
||||
pay(@CurrentUser() user: AuthUser, @Param('id') id: string) {
|
||||
return this.tradeService.payOrder(user.actorId, BigInt(id));
|
||||
}
|
||||
|
||||
@Put(':id/address')
|
||||
updateAddress(
|
||||
@CurrentUser() user: AuthUser,
|
||||
@Param('id') id: string,
|
||||
@Body() body: Record<string, unknown>,
|
||||
) {
|
||||
return this.tradeService.updateAddress(user.actorId, BigInt(id), body);
|
||||
}
|
||||
}
|
||||
|
||||
@Controller('partner/orders')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
export class PartnerOrderController {
|
||||
constructor(private readonly tradeService: TradeService) {}
|
||||
|
||||
@Get()
|
||||
list(
|
||||
@CurrentUser() user: AuthUser,
|
||||
@Query('page') page = '1',
|
||||
@Query('pageSize') pageSize = '20',
|
||||
) {
|
||||
return this.tradeService.listPartnerOrders(user.actorId, Number(page), Number(pageSize));
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
detail(@CurrentUser() user: AuthUser, @Param('id') id: string) {
|
||||
return this.tradeService.getPartnerOrder(user.actorId, BigInt(id));
|
||||
}
|
||||
|
||||
@Post(':id/mock-advance-delivery')
|
||||
mockAdvance(
|
||||
@CurrentUser() user: AuthUser,
|
||||
@Param('id') id: string,
|
||||
@Body() body: { targetStatus: string },
|
||||
) {
|
||||
return this.tradeService.advanceDelivery(user.actorId, BigInt(id), body.targetStatus);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import { Module, forwardRef } from '@nestjs/common';
|
||||
import { IntegrationsModule } from '../../integrations/integrations.module';
|
||||
import { IamModule } from '../iam/iam.module';
|
||||
import { BenefitModule } from '../benefit/benefit.module';
|
||||
import { TradeController, PartnerOrderController } from './trade.controller';
|
||||
import { TradeService } from './trade.service';
|
||||
|
||||
@Module({
|
||||
imports: [IntegrationsModule, IamModule, forwardRef(() => BenefitModule)],
|
||||
controllers: [TradeController, PartnerOrderController],
|
||||
providers: [TradeService],
|
||||
exports: [TradeService],
|
||||
})
|
||||
export class TradeModule {}
|
||||
@@ -0,0 +1,332 @@
|
||||
import {
|
||||
BadRequestException,
|
||||
Inject,
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
import {
|
||||
calcBenefitAmount,
|
||||
generateOrderNo,
|
||||
orderTabToStatuses,
|
||||
validateMinPurchase,
|
||||
} from '@dukang/domain';
|
||||
import { PrismaService } from '../../common/prisma/prisma.module';
|
||||
import { serializeBigInt } from '../../common/decorators/current-user.decorator';
|
||||
import { BenefitService } from '../benefit/benefit.service';
|
||||
import { PAY_PROVIDER, DELIVERY_PROVIDER } from '../../integrations/integrations.constants';
|
||||
import { IPayProvider } from '../../integrations/pay/pay.interface';
|
||||
import { IDeliveryProvider } from '../../integrations/delivery/delivery.interface';
|
||||
|
||||
@Injectable()
|
||||
export class TradeService {
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly benefitService: BenefitService,
|
||||
@Inject(PAY_PROVIDER) private readonly payProvider: IPayProvider,
|
||||
@Inject(DELIVERY_PROVIDER) private readonly deliveryProvider: IDeliveryProvider,
|
||||
) {}
|
||||
|
||||
async preview(userId: bigint, body: { productId: string; quantity: number; addressId?: string }) {
|
||||
const product = await this.prisma.product.findUnique({
|
||||
where: { id: BigInt(body.productId) },
|
||||
});
|
||||
if (!product || product.status !== 'ON_SALE') {
|
||||
throw new BadRequestException('商品不可购买');
|
||||
}
|
||||
const city = await this.prisma.city.findFirst({ where: { status: 'ACTIVE' } });
|
||||
if (!city) throw new BadRequestException('暂无开城城市');
|
||||
|
||||
let deliveryType: 'LOCAL' | 'CROSS_CITY' = 'LOCAL';
|
||||
if (body.addressId) {
|
||||
const address = await this.prisma.userAddress.findFirst({
|
||||
where: { id: BigInt(body.addressId), userId },
|
||||
});
|
||||
if (address && address.city !== city.name && address.city !== '郑州市') {
|
||||
deliveryType = 'CROSS_CITY';
|
||||
}
|
||||
}
|
||||
|
||||
const check = validateMinPurchase(
|
||||
deliveryType,
|
||||
body.quantity,
|
||||
city.localMinQty,
|
||||
city.crossMinQty,
|
||||
);
|
||||
if (!check.ok) throw new BadRequestException(check.message);
|
||||
|
||||
const unitPrice = Number(product.price);
|
||||
const productAmount = unitPrice * body.quantity;
|
||||
const benefitPerUnit = calcBenefitAmount({
|
||||
price: unitPrice,
|
||||
benefitAmount: product.benefitAmount ? Number(product.benefitAmount) : null,
|
||||
});
|
||||
|
||||
return {
|
||||
product: serializeBigInt(product),
|
||||
quantity: body.quantity,
|
||||
deliveryType,
|
||||
productAmount,
|
||||
freightAmount: deliveryType === 'CROSS_CITY' ? 0 : 0,
|
||||
freightPayType: deliveryType === 'CROSS_CITY' ? 'COD' : null,
|
||||
payAmount: productAmount,
|
||||
benefitAmount: benefitPerUnit * body.quantity,
|
||||
city: serializeBigInt(city),
|
||||
};
|
||||
}
|
||||
|
||||
async createOrder(
|
||||
userId: bigint,
|
||||
body: {
|
||||
productId: string;
|
||||
quantity: number;
|
||||
addressId: string;
|
||||
},
|
||||
) {
|
||||
const preview = await this.preview(userId, body);
|
||||
const address = await this.prisma.userAddress.findFirst({
|
||||
where: { id: BigInt(body.addressId), userId },
|
||||
});
|
||||
if (!address) throw new BadRequestException('请选择收货地址');
|
||||
|
||||
const product = await this.prisma.product.findUniqueOrThrow({
|
||||
where: { id: BigInt(body.productId) },
|
||||
});
|
||||
const city = await this.prisma.city.findFirstOrThrow({ where: { status: 'ACTIVE' } });
|
||||
const orderNo = generateOrderNo();
|
||||
const payExpireAt = new Date(Date.now() + 30 * 60 * 1000);
|
||||
|
||||
const order = await this.prisma.order.create({
|
||||
data: {
|
||||
orderNo,
|
||||
userId,
|
||||
cityId: city.id,
|
||||
status: 'PENDING_PAY',
|
||||
deliveryType: preview.deliveryType as 'LOCAL' | 'CROSS_CITY',
|
||||
receiverName: address.receiverName,
|
||||
receiverPhone: address.phone,
|
||||
receiverAddress: `${address.province}${address.city}${address.district}${address.detail}`,
|
||||
receiverProvince: address.province,
|
||||
receiverCity: address.city,
|
||||
receiverDistrict: address.district,
|
||||
productAmount: preview.productAmount,
|
||||
freightAmount: preview.freightAmount,
|
||||
freightPayType: preview.freightPayType,
|
||||
payAmount: preview.payAmount,
|
||||
benefitAmount: preview.benefitAmount,
|
||||
payExpireAt,
|
||||
items: {
|
||||
create: {
|
||||
productId: product.id,
|
||||
productName: product.name,
|
||||
productSpec: product.spec,
|
||||
productImage: product.mainImageUrl,
|
||||
unitPrice: product.price,
|
||||
quantity: body.quantity,
|
||||
subtotal: preview.productAmount,
|
||||
},
|
||||
},
|
||||
payment: {
|
||||
create: {
|
||||
paymentNo: `PAY${orderNo}`,
|
||||
amount: preview.payAmount,
|
||||
status: 'PENDING',
|
||||
},
|
||||
},
|
||||
},
|
||||
include: { items: true, payment: true },
|
||||
});
|
||||
|
||||
return serializeBigInt(order);
|
||||
}
|
||||
|
||||
async payOrder(userId: bigint, orderId: bigint) {
|
||||
const order = await this.prisma.order.findFirst({
|
||||
where: { id: orderId, userId },
|
||||
include: { items: true, payment: true },
|
||||
});
|
||||
if (!order) throw new NotFoundException('订单不存在');
|
||||
if (order.status !== 'PENDING_PAY') {
|
||||
throw new BadRequestException('订单状态不可支付');
|
||||
}
|
||||
|
||||
const { externalNo } = await this.payProvider.payOrder(orderId);
|
||||
const now = new Date();
|
||||
|
||||
await this.prisma.$transaction(async (tx) => {
|
||||
await tx.payment.update({
|
||||
where: { orderId: order.id },
|
||||
data: {
|
||||
status: 'SUCCESS',
|
||||
paidAt: now,
|
||||
wxTransactionId: externalNo,
|
||||
},
|
||||
});
|
||||
await tx.order.update({
|
||||
where: { id: order.id },
|
||||
data: { status: 'PENDING_SHIP', paidAt: now },
|
||||
});
|
||||
await tx.orderStatusLog.create({
|
||||
data: {
|
||||
orderId: order.id,
|
||||
fromStatus: 'PENDING_PAY',
|
||||
toStatus: 'PENDING_SHIP',
|
||||
operator: 'MOCK_PAY',
|
||||
},
|
||||
});
|
||||
await tx.orderDelivery.create({
|
||||
data: { orderId: order.id, provider: 'MOCK' },
|
||||
});
|
||||
});
|
||||
|
||||
await this.benefitService.grantOnOrderPaid(order.id);
|
||||
await this.deliveryProvider.scheduleAutoAdvance(order.id);
|
||||
|
||||
return this.getOrder(userId, orderId);
|
||||
}
|
||||
|
||||
async listOrders(userId: bigint, tab = 'all', page = 1, pageSize = 20) {
|
||||
const statuses = orderTabToStatuses(tab);
|
||||
const where = {
|
||||
userId,
|
||||
...(statuses ? { status: { in: statuses as never[] } } : {}),
|
||||
};
|
||||
const [list, total] = await Promise.all([
|
||||
this.prisma.order.findMany({
|
||||
where,
|
||||
include: { items: true, benefitCoupons: true },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
skip: (page - 1) * pageSize,
|
||||
take: pageSize,
|
||||
}),
|
||||
this.prisma.order.count({ where }),
|
||||
]);
|
||||
return { list: serializeBigInt(list), total, page, pageSize };
|
||||
}
|
||||
|
||||
async getOrder(userId: bigint, orderId: bigint) {
|
||||
const order = await this.prisma.order.findFirst({
|
||||
where: { id: orderId, userId },
|
||||
include: {
|
||||
items: true,
|
||||
delivery: true,
|
||||
payment: true,
|
||||
benefitCoupons: true,
|
||||
statusLogs: { orderBy: { createdAt: 'desc' } },
|
||||
},
|
||||
});
|
||||
if (!order) throw new NotFoundException('订单不存在');
|
||||
return serializeBigInt(order);
|
||||
}
|
||||
|
||||
async updateAddress(userId: bigint, orderId: bigint, body: Record<string, unknown>) {
|
||||
const order = await this.prisma.order.findFirst({ where: { id: orderId, userId } });
|
||||
if (!order) throw new NotFoundException('订单不存在');
|
||||
if (!['PENDING_PAY', 'PENDING_SHIP', 'OUT_WAREHOUSE'].includes(order.status)) {
|
||||
throw new BadRequestException('当前状态不可修改地址');
|
||||
}
|
||||
const updated = await this.prisma.order.update({
|
||||
where: { id: orderId },
|
||||
data: {
|
||||
receiverName: String(body.receiverName ?? order.receiverName),
|
||||
receiverPhone: String(body.receiverPhone ?? order.receiverPhone),
|
||||
receiverProvince: String(body.receiverProvince ?? order.receiverProvince),
|
||||
receiverCity: String(body.receiverCity ?? order.receiverCity),
|
||||
receiverDistrict: String(body.receiverDistrict ?? order.receiverDistrict),
|
||||
receiverAddress: String(body.receiverAddress ?? order.receiverAddress),
|
||||
},
|
||||
});
|
||||
await this.prisma.orderStatusLog.create({
|
||||
data: {
|
||||
orderId,
|
||||
fromStatus: order.status,
|
||||
toStatus: order.status,
|
||||
operator: 'USER',
|
||||
remark: '修改收货地址',
|
||||
},
|
||||
});
|
||||
return serializeBigInt(updated);
|
||||
}
|
||||
|
||||
async listPartnerOrders(partnerAccountId: bigint, page = 1, pageSize = 20) {
|
||||
const account = await this.prisma.partnerAccount.findUniqueOrThrow({
|
||||
where: { id: partnerAccountId },
|
||||
});
|
||||
const cities = await this.prisma.city.findMany({ where: { partnerId: account.partnerId } });
|
||||
const cityIds = cities.map((c) => c.id);
|
||||
const where = { cityId: { in: cityIds } };
|
||||
const [list, total] = await Promise.all([
|
||||
this.prisma.order.findMany({
|
||||
where,
|
||||
include: { items: true, delivery: true, user: { select: { phone: true, nickname: true } } },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
skip: (page - 1) * pageSize,
|
||||
take: pageSize,
|
||||
}),
|
||||
this.prisma.order.count({ where }),
|
||||
]);
|
||||
return { list: serializeBigInt(list), total, page, pageSize };
|
||||
}
|
||||
|
||||
async getPartnerOrder(partnerAccountId: bigint, orderId: bigint) {
|
||||
const account = await this.prisma.partnerAccount.findUniqueOrThrow({
|
||||
where: { id: partnerAccountId },
|
||||
});
|
||||
const cities = await this.prisma.city.findMany({ where: { partnerId: account.partnerId } });
|
||||
const order = await this.prisma.order.findFirst({
|
||||
where: { id: orderId, cityId: { in: cities.map((c) => c.id) } },
|
||||
include: { items: true, delivery: true, statusLogs: true, user: true },
|
||||
});
|
||||
if (!order) throw new NotFoundException('订单不存在');
|
||||
return serializeBigInt(order);
|
||||
}
|
||||
|
||||
async advanceDelivery(partnerAccountId: bigint, orderId: bigint, targetStatus: string) {
|
||||
const account = await this.prisma.partnerAccount.findUniqueOrThrow({
|
||||
where: { id: partnerAccountId },
|
||||
});
|
||||
const order = await this.prisma.order.findFirst({
|
||||
where: {
|
||||
id: orderId,
|
||||
city: { partnerId: account.partnerId },
|
||||
},
|
||||
include: { delivery: true },
|
||||
});
|
||||
if (!order) throw new NotFoundException('订单不存在');
|
||||
await this.applyStatusTransition(order.id, order.status, targetStatus);
|
||||
return this.getPartnerOrder(partnerAccountId, orderId);
|
||||
}
|
||||
|
||||
async applyStatusTransition(orderId: bigint, fromStatus: string, targetStatus: string) {
|
||||
const order = await this.prisma.order.findUnique({ where: { id: orderId } });
|
||||
if (!order) return;
|
||||
const currentStatus = fromStatus || order.status;
|
||||
const now = new Date();
|
||||
const data: Record<string, unknown> = { status: targetStatus };
|
||||
const deliveryData: Record<string, unknown> = {};
|
||||
|
||||
if (targetStatus === 'OUT_WAREHOUSE') deliveryData.outWarehouseAt = now;
|
||||
if (targetStatus === 'SHIPPING') {
|
||||
deliveryData.shippingAt = now;
|
||||
data.shippedAt = now;
|
||||
}
|
||||
if (targetStatus === 'COMPLETED') {
|
||||
deliveryData.deliveredAt = now;
|
||||
data.completedAt = now;
|
||||
}
|
||||
|
||||
await this.prisma.$transaction(async (tx) => {
|
||||
await tx.order.update({ where: { id: orderId }, data: data as never });
|
||||
if (Object.keys(deliveryData).length) {
|
||||
await tx.orderDelivery.update({ where: { orderId }, data: deliveryData as never });
|
||||
}
|
||||
await tx.orderStatusLog.create({
|
||||
data: {
|
||||
orderId,
|
||||
fromStatus: currentStatus,
|
||||
toStatus: targetStatus,
|
||||
operator: 'MOCK',
|
||||
},
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user