This commit is contained in:
2026-06-30 10:33:56 +08:00
commit 6e047dc0a5
607 changed files with 65966 additions and 0 deletions
@@ -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',
},
});
});
}
}