v3数据表更改

This commit is contained in:
2026-07-01 14:36:50 +08:00
parent 638898b71e
commit aeb4ecfc84
46 changed files with 4553 additions and 918 deletions
@@ -1,4 +1,5 @@
import { Injectable } from '@nestjs/common';
import type { ClientApp } from '@prisma/client';
import { PrismaService } from '../../common/prisma/prisma.module';
@Injectable()
@@ -11,12 +12,12 @@ export class AnalyticsService {
events: Array<{ eventName: string; params?: Record<string, unknown> }>,
) {
if (!events?.length) return { count: 0 };
await this.prisma.eventLog.createMany({
await this.prisma.logUserAnalytics.createMany({
data: events.map((e) => ({
userId,
eventName: e.eventName,
params: e.params as never,
clientApp,
extraJson: e.params as never,
clientApp: clientApp as ClientApp,
})),
});
return { count: events.length };
@@ -2,6 +2,7 @@ import { Injectable } from '@nestjs/common';
import { calcBenefitAmount, calcBenefitSummary, generateCouponNo } from '@dukang/domain';
import { PrismaService } from '../../common/prisma/prisma.module';
import { serializeBigInt } from '../../common/decorators/current-user.decorator';
import { buildBenefitLedgerEvent, benefitLedgerWhere } from '../../common/event/event.helpers';
@Injectable()
export class BenefitService {
@@ -10,17 +11,14 @@ export class BenefitService {
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 product = await this.prisma.commonProductItem.findUnique({ where: { id: order.productId } });
const unitBenefit = calcBenefitAmount({
price: Number(item.unitPrice),
price: Number(order.listUnitPrice),
benefitAmount: product?.benefitAmount ? Number(product.benefitAmount) : null,
});
const totalBenefit = unitBenefit * item.quantity;
const totalBenefit = unitBenefit * order.quantity;
const coupon = await this.prisma.benefitCoupon.create({
data: {
@@ -29,12 +27,12 @@ export class BenefitService {
orderId: order.id,
totalAmount: totalBenefit,
balance: totalBenefit,
sourceProduct: item.productName,
sourceProduct: order.productName,
},
});
await this.prisma.benefitLedger.create({
data: {
await this.prisma.commonEvent.create({
data: buildBenefitLedgerEvent({
userId: order.userId,
couponId: coupon.id,
type: 'GRANT',
@@ -43,7 +41,7 @@ export class BenefitService {
refType: 'ORDER',
refId: order.id,
remark: '购酒赠券',
},
}),
});
return serializeBigInt(coupon);
@@ -69,8 +67,8 @@ export class BenefitService {
}
async getLedger(userId: bigint, couponId?: bigint) {
const list = await this.prisma.benefitLedger.findMany({
where: { userId, ...(couponId ? { couponId } : {}) },
const list = await this.prisma.commonEvent.findMany({
where: benefitLedgerWhere(userId, couponId),
orderBy: { createdAt: 'desc' },
});
return serializeBigInt(list);
@@ -81,8 +79,8 @@ export class BenefitService {
where: { id: couponId, userId },
});
if (!coupon) return null;
const ledgers = await this.prisma.benefitLedger.findMany({
where: { couponId },
const ledgers = await this.prisma.commonEvent.findMany({
where: benefitLedgerWhere(undefined, couponId),
orderBy: { createdAt: 'desc' },
});
return serializeBigInt({ coupon, ledgers });
@@ -7,7 +7,7 @@ export class CatalogService {
constructor(private readonly prisma: PrismaService) {}
async listCities() {
const cities = await this.prisma.city.findMany({
const cities = await this.prisma.commonCity.findMany({
where: { status: 'ACTIVE' },
include: { partner: { select: { companyName: true } } },
});
@@ -15,9 +15,10 @@ export class CatalogService {
}
async listProducts(aromaType?: string) {
const products = await this.prisma.product.findMany({
const products = await this.prisma.commonProductItem.findMany({
where: { status: 'ON_SALE', ...(aromaType ? { aromaType: aromaType as never } : {}) },
orderBy: { sortOrder: 'asc' },
include: { coverResource: true },
});
return serializeBigInt(
products.map((p) => ({
@@ -25,17 +26,22 @@ export class CatalogService {
benefitAmount: p.benefitAmount ?? p.price,
price: Number(p.price),
benefitDisplay: Number(p.benefitAmount ?? p.price),
mainImageUrl: p.coverResource?.url ?? null,
})),
);
}
async getProduct(id: bigint) {
const product = await this.prisma.product.findUnique({ where: { id } });
const product = await this.prisma.commonProductItem.findUnique({
where: { id },
include: { coverResource: true },
});
if (!product) return null;
return serializeBigInt({
...product,
benefitAmount: product.benefitAmount ?? product.price,
price: Number(product.price),
mainImageUrl: product.coverResource?.url ?? null,
});
}
}
@@ -0,0 +1,18 @@
import { Module } from '@nestjs/common';
import { IamModule } from '../iam/iam.module';
import { ResourceService } from './resource.service';
import { EventService } from './event.service';
import { TicketService } from './ticket.service';
import { ThirdPartyLogService } from './third-party-log.service';
import { ResourceController } from './resource.controller';
import { EventController } from './event.controller';
import { TicketController } from './ticket.controller';
import { ThirdPartyLogController } from './third-party-log.controller';
@Module({
imports: [IamModule],
controllers: [ResourceController, EventController, TicketController, ThirdPartyLogController],
providers: [ResourceService, EventService, TicketService, ThirdPartyLogService],
exports: [ResourceService, EventService, TicketService],
})
export class CommonModule {}
@@ -0,0 +1,178 @@
import { IsIn, IsNotEmpty, IsOptional, IsString } from 'class-validator';
export class UploadTokenDto {
@IsString()
@IsNotEmpty()
bizType: string;
@IsString()
@IsIn(['IMAGE', 'VIDEO', 'FILE'])
mediaType: string;
@IsString()
@IsNotEmpty()
fileName: string;
}
export class RegisterResourceDto {
@IsString()
@IsNotEmpty()
ownerType: string;
@IsString()
@IsNotEmpty()
ownerId: string;
@IsString()
@IsNotEmpty()
bizType: string;
@IsString()
@IsIn(['IMAGE', 'VIDEO', 'FILE'])
mediaType: string;
@IsString()
@IsNotEmpty()
ossKey: string;
@IsString()
@IsNotEmpty()
url: string;
@IsOptional()
@IsString()
ossBucket?: string;
@IsOptional()
@IsString()
fileName?: string;
@IsOptional()
sortOrder?: number;
}
export class UpdateResourceDto {
@IsOptional()
@IsString()
url?: string;
@IsOptional()
@IsIn(['IMAGE', 'VIDEO', 'FILE'])
mediaType?: string;
@IsOptional()
sortOrder?: number;
@IsOptional()
@IsIn(['ACTIVE', 'DELETED'])
status?: string;
}
export class CreateEventDto {
@IsString()
@IsNotEmpty()
eventType: string;
@IsString()
@IsNotEmpty()
refType: string;
@IsString()
@IsNotEmpty()
refId: string;
@IsOptional()
@IsString()
actorType?: string;
@IsOptional()
@IsString()
actorId?: string;
@IsOptional()
@IsString()
status?: string;
@IsOptional()
@IsString()
param1?: string;
@IsOptional()
@IsString()
param1Desc?: string;
@IsOptional()
@IsString()
param2?: string;
@IsOptional()
@IsString()
param2Desc?: string;
@IsOptional()
@IsString()
param3?: string;
@IsOptional()
@IsString()
param3Desc?: string;
@IsOptional()
amount1?: number;
@IsOptional()
amount2?: number;
@IsOptional()
@IsString()
remark?: string;
@IsOptional()
extraJson?: Record<string, unknown>;
}
export class CreateTicketDto {
@IsString()
@IsIn(['REFUND', 'RESHIPMENT', 'ALERT'])
ticketType: string;
@IsString()
@IsNotEmpty()
refType: string;
@IsString()
@IsNotEmpty()
refId: string;
@IsOptional()
@IsString()
remark?: string;
@IsOptional()
@IsString()
param1?: string;
@IsOptional()
@IsString()
param1Desc?: string;
}
export class UpdateTicketStatusDto {
@IsString()
@IsNotEmpty()
status: string;
@IsOptional()
@IsString()
remark?: string;
}
export class AssignTicketDto {
@IsString()
@IsIn(['HQ', 'PARTNER', 'SYSTEM'])
operatorType: string;
@IsString()
@IsNotEmpty()
operatorId: string;
}
@@ -0,0 +1,93 @@
import { Type } from 'class-transformer';
import { IsIn, IsInt, IsOptional, IsString, Max, Min } from 'class-validator';
export class PaginationQueryDto {
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(1)
page?: number = 1;
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(1)
@Max(100)
pageSize?: number = 20;
}
export class ResourceListQueryDto extends PaginationQueryDto {
@IsOptional()
@IsString()
ownerType?: string;
@IsOptional()
@IsString()
ownerId?: string;
@IsOptional()
@IsString()
bizType?: string;
@IsOptional()
@IsIn(['ACTIVE', 'DELETED'])
status?: string;
}
export class EventListQueryDto extends PaginationQueryDto {
@IsOptional()
@IsString()
refType?: string;
@IsOptional()
@IsString()
refId?: string;
@IsOptional()
@IsString()
eventType?: string;
}
export class EventTimelineQueryDto {
@IsString()
refType: string;
@IsString()
refId: string;
}
export class TicketListQueryDto extends PaginationQueryDto {
@IsOptional()
@IsString()
ticketType?: string;
@IsOptional()
@IsString()
status?: string;
@IsOptional()
@IsString()
refType?: string;
@IsOptional()
@IsString()
refId?: string;
}
export class ThirdPartyLogQueryDto extends PaginationQueryDto {
@IsOptional()
@IsString()
provider?: string;
@IsOptional()
@IsString()
scene?: string;
@IsOptional()
@IsString()
refType?: string;
@IsOptional()
@IsString()
refId?: string;
}
@@ -0,0 +1,31 @@
import { Body, Controller, Get, Param, Post, Query, UseGuards } from '@nestjs/common';
import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard';
import { EventService } from './event.service';
import { EventListQueryDto, EventTimelineQueryDto } from './dto/common-query.dto';
import { CreateEventDto } from './dto/common-mutate.dto';
@Controller('common/events')
@UseGuards(JwtAuthGuard)
export class EventController {
constructor(private readonly service: EventService) {}
@Post()
create(@Body() dto: CreateEventDto) {
return this.service.create(dto);
}
@Get()
list(@Query() query: EventListQueryDto) {
return this.service.list(query);
}
@Get('timeline')
timeline(@Query() query: EventTimelineQueryDto) {
return this.service.timeline(query);
}
@Get(':id')
detail(@Param('id') id: string) {
return this.service.detail(BigInt(id));
}
}
@@ -0,0 +1,71 @@
import { Injectable, NotFoundException } from '@nestjs/common';
import type { ActorType, EventType, ResourceBizType, ResourceMediaType, ResourceOwnerType } from '@prisma/client';
import { Prisma } from '@prisma/client';
import { PrismaService } from '../../common/prisma/prisma.module';
import { serializeBigInt } from '../../common/decorators/current-user.decorator';
import type { EventListQueryDto, EventTimelineQueryDto } from './dto/common-query.dto';
import type { CreateEventDto } from './dto/common-mutate.dto';
@Injectable()
export class EventService {
constructor(private readonly prisma: PrismaService) {}
async create(dto: CreateEventDto) {
const event = await this.prisma.commonEvent.create({
data: {
eventType: dto.eventType as EventType,
refType: dto.refType,
refId: BigInt(dto.refId),
actorType: dto.actorType as ActorType | undefined,
actorId: dto.actorId ? BigInt(dto.actorId) : undefined,
status: dto.status,
param1: dto.param1,
param1Desc: dto.param1Desc,
param2: dto.param2,
param2Desc: dto.param2Desc,
param3: dto.param3,
param3Desc: dto.param3Desc,
amount1: dto.amount1,
amount2: dto.amount2,
remark: dto.remark,
extraJson: dto.extraJson as Prisma.InputJsonValue | undefined,
},
});
return serializeBigInt(event);
}
async list(query: EventListQueryDto) {
const page = query.page ?? 1;
const pageSize = query.pageSize ?? 20;
const where: Prisma.CommonEventWhereInput = {};
if (query.refType) where.refType = query.refType;
if (query.refId) where.refId = BigInt(query.refId);
if (query.eventType) where.eventType = query.eventType as Prisma.EnumEventTypeFilter['equals'];
const [items, total] = await Promise.all([
this.prisma.commonEvent.findMany({
where,
orderBy: { createdAt: 'desc' },
skip: (page - 1) * pageSize,
take: pageSize,
}),
this.prisma.commonEvent.count({ where }),
]);
return serializeBigInt({ items, total, page, pageSize });
}
async timeline(query: EventTimelineQueryDto) {
const items = await this.prisma.commonEvent.findMany({
where: { refType: query.refType, refId: BigInt(query.refId) },
orderBy: { createdAt: 'asc' },
take: 200,
});
return serializeBigInt(items);
}
async detail(id: bigint) {
const event = await this.prisma.commonEvent.findUnique({ where: { id } });
if (!event) throw new NotFoundException('事件不存在');
return serializeBigInt(event);
}
}
@@ -0,0 +1,41 @@
import { Body, Controller, Delete, Get, Param, Post, Put, Query, UseGuards } from '@nestjs/common';
import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard';
import { ResourceService } from './resource.service';
import { ResourceListQueryDto } from './dto/common-query.dto';
import { RegisterResourceDto, UpdateResourceDto, UploadTokenDto } from './dto/common-mutate.dto';
@Controller('common/resources')
@UseGuards(JwtAuthGuard)
export class ResourceController {
constructor(private readonly service: ResourceService) {}
@Post('upload-token')
uploadToken(@Body() dto: UploadTokenDto) {
return this.service.getUploadToken(dto);
}
@Post()
register(@Body() dto: RegisterResourceDto) {
return this.service.register(dto);
}
@Get()
list(@Query() query: ResourceListQueryDto) {
return this.service.list(query);
}
@Get(':id')
detail(@Param('id') id: string) {
return this.service.detail(BigInt(id));
}
@Put(':id')
update(@Param('id') id: string, @Body() dto: UpdateResourceDto) {
return this.service.update(BigInt(id), dto);
}
@Delete(':id')
remove(@Param('id') id: string) {
return this.service.remove(BigInt(id));
}
}
@@ -0,0 +1,98 @@
import { Injectable, NotFoundException } from '@nestjs/common';
import type { ResourceBizType, ResourceMediaType, ResourceOwnerType } from '@prisma/client';
import { Prisma } from '@prisma/client';
import { randomUUID } from 'crypto';
import { PrismaService } from '../../common/prisma/prisma.module';
import { serializeBigInt } from '../../common/decorators/current-user.decorator';
import type { ResourceListQueryDto } from './dto/common-query.dto';
import type { RegisterResourceDto, UpdateResourceDto, UploadTokenDto } from './dto/common-mutate.dto';
@Injectable()
export class ResourceService {
constructor(private readonly prisma: PrismaService) {}
getUploadToken(dto: UploadTokenDto) {
const bucket = process.env.OSS_BUCKET || 'mock-dukang';
const ext = dto.fileName.includes('.') ? dto.fileName.split('.').pop() : 'bin';
const key = `uploads/${dto.bizType.toLowerCase()}/${Date.now()}-${randomUUID().slice(0, 8)}.${ext}`;
const cdnBase = process.env.OSS_CDN_BASE || 'https://mock-cdn.dukang.local';
return {
bucket,
region: process.env.OSS_REGION || 'oss-cn-hangzhou',
ossKey: key,
url: `${cdnBase}/${key}`,
mock: true,
expireAt: new Date(Date.now() + 15 * 60 * 1000).toISOString(),
mediaType: dto.mediaType,
bizType: dto.bizType,
};
}
async register(dto: RegisterResourceDto) {
const resource = await this.prisma.commonResource.create({
data: {
ownerType: dto.ownerType as ResourceOwnerType,
ownerId: BigInt(dto.ownerId),
bizType: dto.bizType as ResourceBizType,
mediaType: dto.mediaType as ResourceMediaType,
ossBucket: dto.ossBucket ?? process.env.OSS_BUCKET ?? 'mock-dukang',
ossKey: dto.ossKey,
url: dto.url,
fileName: dto.fileName,
sortOrder: dto.sortOrder ?? 0,
},
});
return serializeBigInt(resource);
}
async list(query: ResourceListQueryDto) {
const page = query.page ?? 1;
const pageSize = query.pageSize ?? 20;
const where: Prisma.CommonResourceWhereInput = {
status: (query.status ?? 'ACTIVE') as Prisma.EnumResourceStatusFilter['equals'],
};
if (query.ownerType) where.ownerType = query.ownerType as Prisma.EnumResourceOwnerTypeFilter['equals'];
if (query.ownerId) where.ownerId = BigInt(query.ownerId);
if (query.bizType) where.bizType = query.bizType as Prisma.EnumResourceBizTypeFilter['equals'];
const [items, total] = await Promise.all([
this.prisma.commonResource.findMany({
where,
orderBy: [{ sortOrder: 'asc' }, { createdAt: 'desc' }],
skip: (page - 1) * pageSize,
take: pageSize,
}),
this.prisma.commonResource.count({ where }),
]);
return serializeBigInt({ items, total, page, pageSize });
}
async detail(id: bigint) {
const resource = await this.prisma.commonResource.findUnique({ where: { id } });
if (!resource) throw new NotFoundException('资源不存在');
return serializeBigInt(resource);
}
async update(id: bigint, dto: UpdateResourceDto) {
await this.detail(id);
const resource = await this.prisma.commonResource.update({
where: { id },
data: {
...(dto.url !== undefined ? { url: dto.url, ossKey: dto.url } : {}),
...(dto.mediaType !== undefined ? { mediaType: dto.mediaType as 'IMAGE' | 'VIDEO' | 'FILE' } : {}),
...(dto.sortOrder !== undefined ? { sortOrder: dto.sortOrder } : {}),
...(dto.status !== undefined ? { status: dto.status as 'ACTIVE' | 'DELETED' } : {}),
},
});
return serializeBigInt(resource);
}
async remove(id: bigint) {
await this.detail(id);
await this.prisma.commonResource.update({
where: { id },
data: { status: 'DELETED' },
});
return { ok: true };
}
}
@@ -0,0 +1,20 @@
import { Controller, Get, Param, Query, UseGuards } from '@nestjs/common';
import { HqAuthGuard } from '../../common/guards/hq-auth.guard';
import { ThirdPartyLogService } from './third-party-log.service';
import { ThirdPartyLogQueryDto } from './dto/common-query.dto';
@Controller('common/third-party-logs')
@UseGuards(HqAuthGuard)
export class ThirdPartyLogController {
constructor(private readonly service: ThirdPartyLogService) {}
@Get()
list(@Query() query: ThirdPartyLogQueryDto) {
return this.service.list(query);
}
@Get(':id')
detail(@Param('id') id: string) {
return this.service.detail(BigInt(id));
}
}
@@ -0,0 +1,37 @@
import { Injectable, NotFoundException } from '@nestjs/common';
import { Prisma } from '@prisma/client';
import { PrismaService } from '../../common/prisma/prisma.module';
import { serializeBigInt } from '../../common/decorators/current-user.decorator';
import type { ThirdPartyLogQueryDto } from './dto/common-query.dto';
@Injectable()
export class ThirdPartyLogService {
constructor(private readonly prisma: PrismaService) {}
async list(query: ThirdPartyLogQueryDto) {
const page = query.page ?? 1;
const pageSize = query.pageSize ?? 20;
const where: Prisma.LogThirdPartyWhereInput = {};
if (query.provider) where.provider = query.provider as Prisma.EnumThirdPartyProviderFilter['equals'];
if (query.scene) where.scene = { contains: query.scene };
if (query.refType) where.refType = query.refType;
if (query.refId) where.refId = BigInt(query.refId);
const [items, total] = await Promise.all([
this.prisma.logThirdParty.findMany({
where,
orderBy: { createdAt: 'desc' },
skip: (page - 1) * pageSize,
take: pageSize,
}),
this.prisma.logThirdParty.count({ where }),
]);
return serializeBigInt({ items, total, page, pageSize });
}
async detail(id: bigint) {
const log = await this.prisma.logThirdParty.findUnique({ where: { id } });
if (!log) throw new NotFoundException('日志不存在');
return serializeBigInt(log);
}
}
@@ -0,0 +1,38 @@
import { Body, Controller, Get, Param, Post, Put, Query, UseGuards } from '@nestjs/common';
import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard';
import { HqAuthGuard } from '../../common/guards/hq-auth.guard';
import { TicketService } from './ticket.service';
import { TicketListQueryDto } from './dto/common-query.dto';
import { AssignTicketDto, CreateTicketDto, UpdateTicketStatusDto } from './dto/common-mutate.dto';
@Controller('common/tickets')
@UseGuards(JwtAuthGuard)
export class TicketController {
constructor(private readonly service: TicketService) {}
@Post()
create(@Body() dto: CreateTicketDto) {
return this.service.create(dto);
}
@Get()
list(@Query() query: TicketListQueryDto) {
return this.service.list(query);
}
@Get(':id')
detail(@Param('id') id: string) {
return this.service.detail(BigInt(id));
}
@Put(':id/status')
updateStatus(@Param('id') id: string, @Body() dto: UpdateTicketStatusDto) {
return this.service.updateStatus(BigInt(id), dto);
}
@Put(':id/assign')
@UseGuards(HqAuthGuard)
assign(@Param('id') id: string, @Body() dto: AssignTicketDto) {
return this.service.assign(BigInt(id), dto);
}
}
@@ -0,0 +1,83 @@
import { Injectable, NotFoundException } from '@nestjs/common';
import type { ActorType, TicketType } from '@prisma/client';
import { Prisma } from '@prisma/client';
import { PrismaService } from '../../common/prisma/prisma.module';
import { serializeBigInt } from '../../common/decorators/current-user.decorator';
import type { TicketListQueryDto } from './dto/common-query.dto';
import type { AssignTicketDto, CreateTicketDto, UpdateTicketStatusDto } from './dto/common-mutate.dto';
function generateTicketNo() {
return `TK${Date.now()}${Math.floor(Math.random() * 900 + 100)}`;
}
@Injectable()
export class TicketService {
constructor(private readonly prisma: PrismaService) {}
async create(dto: CreateTicketDto) {
const ticket = await this.prisma.commonTicket.create({
data: {
ticketNo: generateTicketNo(),
ticketType: dto.ticketType as TicketType,
refType: dto.refType,
refId: BigInt(dto.refId),
remark: dto.remark,
param1: dto.param1,
param1Desc: dto.param1Desc,
},
});
return serializeBigInt(ticket);
}
async list(query: TicketListQueryDto) {
const page = query.page ?? 1;
const pageSize = query.pageSize ?? 20;
const where: Prisma.CommonTicketWhereInput = {};
if (query.ticketType) where.ticketType = query.ticketType as Prisma.EnumTicketTypeFilter['equals'];
if (query.status) where.status = query.status;
if (query.refType) where.refType = query.refType;
if (query.refId) where.refId = BigInt(query.refId);
const [items, total] = await Promise.all([
this.prisma.commonTicket.findMany({
where,
orderBy: { createdAt: 'desc' },
skip: (page - 1) * pageSize,
take: pageSize,
}),
this.prisma.commonTicket.count({ where }),
]);
return serializeBigInt({ items, total, page, pageSize });
}
async detail(id: bigint) {
const ticket = await this.prisma.commonTicket.findUnique({ where: { id } });
if (!ticket) throw new NotFoundException('工单不存在');
return serializeBigInt(ticket);
}
async updateStatus(id: bigint, dto: UpdateTicketStatusDto) {
await this.detail(id);
const ticket = await this.prisma.commonTicket.update({
where: { id },
data: {
status: dto.status,
remark: dto.remark,
completedAt: ['COMPLETED', 'CLOSED', 'RESOLVED'].includes(dto.status) ? new Date() : undefined,
},
});
return serializeBigInt(ticket);
}
async assign(id: bigint, dto: AssignTicketDto) {
await this.detail(id);
const ticket = await this.prisma.commonTicket.update({
where: { id },
data: {
operatorType: dto.operatorType as ActorType,
operatorId: BigInt(dto.operatorId),
},
});
return serializeBigInt(ticket);
}
}
@@ -27,11 +27,13 @@ type UserRow = Pick<
| 'phone'
| 'phoneVerifiedAt'
| 'nickname'
| 'avatarUrl'
| 'avatarResourceId'
| 'wxOpenId'
| 'mergedIntoUserId'
| 'status'
>;
> & {
avatar?: { url: string } | null;
};
@Injectable()
export class AuthService {
@@ -57,6 +59,7 @@ export class AuthService {
status: 1,
mergedIntoUserId: null,
},
include: { avatar: true },
});
}
@@ -67,13 +70,14 @@ export class AuthService {
userNo: generateUserNo(),
deviceKey: resolvedDeviceKey,
nickname: '访客',
cityPref: {
cityPreference: {
create: {
selectedCityCode: '410100',
selectedDistrict: '郑州市',
},
},
},
include: { avatar: true },
});
}
@@ -96,7 +100,10 @@ export class AuthService {
async loginUser(phone: string, code: string, clientApp: ClientApp, guestId?: bigint) {
await this.smsProvider.verify(phone, code, SmsScene.USER_LOGIN);
let user: UserRow | null = await this.prisma.user.findUnique({ where: { phone } });
let user: UserRow | null = await this.prisma.user.findUnique({
where: { phone },
include: { avatar: true },
});
if (!user) {
if (guestId) {
@@ -110,6 +117,7 @@ export class AuthService {
phoneVerifiedAt: new Date(),
nickname: guest.nickname === '访客' ? `用户${phone.slice(-4)}` : guest.nickname,
},
include: { avatar: true },
});
}
} catch {
@@ -123,13 +131,14 @@ export class AuthService {
phoneVerifiedAt: new Date(),
userNo: generateUserNo(),
nickname: `用户${phone.slice(-4)}`,
cityPref: {
cityPreference: {
create: {
selectedCityCode: '410100',
selectedDistrict: '郑州市',
},
},
},
include: { avatar: true },
});
}
} else {
@@ -137,6 +146,7 @@ export class AuthService {
user = await this.prisma.user.update({
where: { id: user.id },
data: { phoneVerifiedAt: new Date() },
include: { avatar: true },
});
}
if (guestId && guestId !== user.id) {
@@ -173,6 +183,7 @@ export class AuthService {
phoneVerifiedAt: new Date(),
nickname: guest.nickname === '访客' ? `用户${phone.slice(-4)}` : guest.nickname,
},
include: { avatar: true },
});
} else {
await this.assertActiveUser(existing.id);
@@ -293,9 +304,12 @@ export class AuthService {
await tx.order.updateMany({ where: { userId: guestId }, data: { userId: primaryId } });
await tx.userAddress.updateMany({ where: { userId: guestId }, data: { userId: primaryId } });
await tx.benefitCoupon.updateMany({ where: { userId: guestId }, data: { userId: primaryId } });
await tx.benefitLedger.updateMany({ where: { userId: guestId }, data: { userId: primaryId } });
await tx.commonEvent.updateMany({
where: { actorType: 'USER', actorId: guestId },
data: { actorId: primaryId },
});
await tx.redeemRecord.updateMany({ where: { userId: guestId }, data: { userId: primaryId } });
await tx.eventLog.updateMany({ where: { userId: guestId }, data: { userId: primaryId } });
await tx.logUserAnalytics.updateMany({ where: { userId: guestId }, data: { userId: primaryId } });
const primaryPref = await tx.userCityPreference.findUnique({ where: { userId: primaryId } });
const guestPref = await tx.userCityPreference.findUnique({ where: { userId: guestId } });
@@ -343,7 +357,10 @@ export class AuthService {
}
private async assertActiveUser(userId: bigint): Promise<UserRow> {
const user = await this.prisma.user.findUnique({ where: { id: userId } });
const user = await this.prisma.user.findUnique({
where: { id: userId },
include: { avatar: true },
});
if (!user) throw new NotFoundException('用户不存在');
if (user.mergedIntoUserId) {
throw new UnauthorizedException('账号已合并,请重新进入');
@@ -366,7 +383,7 @@ export class AuthService {
phone: user.phone ? user.phone.replace(/(\d{3})\d{4}(\d{4})/, '$1****$2') : null,
phoneVerified: !!user.phoneVerifiedAt,
nickname: user.nickname,
avatarUrl: user.avatarUrl,
avatarUrl: user.avatar?.url ?? null,
hasWechat: !!user.wxOpenId,
};
}
@@ -2,6 +2,8 @@ import { BadRequestException, Injectable, NotFoundException } from '@nestjs/comm
import { Prisma } from '@prisma/client';
import { PrismaService } from '../../common/prisma/prisma.module';
import { serializeBigInt } from '../../common/decorators/current-user.decorator';
import { buildBenefitLedgerEvent, benefitLedgerWhere } from '../../common/event/event.helpers';
import { mapBenefitLedgerCompat } from '../../common/compat/v31-compat';
import type { AdminBenefitCouponsQueryDto, AdminBenefitLedgersQueryDto } from './dto/admin-query.dto';
@Injectable()
@@ -38,12 +40,16 @@ export class AdminBenefitService {
include: {
user: { select: { id: true, userNo: true, phone: true, nickname: true } },
order: { select: { id: true, orderNo: true, status: true, payAmount: true } },
ledgers: { orderBy: { createdAt: 'desc' }, take: 20 },
redeemRecords: { orderBy: { createdAt: 'desc' }, take: 10, include: { store: { select: { id: true, name: true } } } },
},
});
if (!coupon) throw new NotFoundException('权益券不存在');
return serializeBigInt(coupon);
const ledgers = await this.prisma.commonEvent.findMany({
where: benefitLedgerWhere(undefined, id),
orderBy: { createdAt: 'desc' },
take: 20,
});
return serializeBigInt({ ...coupon, ledgers });
}
async voidCoupon(id: bigint) {
@@ -57,8 +63,8 @@ export class AdminBenefitService {
data: { status: 'VOID', balance: 0 },
});
if (Number(coupon.balance) > 0) {
await tx.benefitLedger.create({
data: {
await tx.commonEvent.create({
data: buildBenefitLedgerEvent({
userId: coupon.userId,
couponId: coupon.id,
type: 'ADJUST',
@@ -66,7 +72,7 @@ export class AdminBenefitService {
balanceAfter: 0,
refType: 'ADMIN_VOID',
remark: 'HQ 手动作废',
},
}),
});
}
return row;
@@ -77,24 +83,47 @@ export class AdminBenefitService {
async listLedgers(query: AdminBenefitLedgersQueryDto) {
const page = query.page ?? 1;
const pageSize = query.pageSize ?? 20;
const where: Prisma.BenefitLedgerWhereInput = {};
if (query.userId) where.userId = BigInt(query.userId);
if (query.couponId) where.couponId = BigInt(query.couponId);
if (query.type) where.type = query.type as Prisma.EnumBenefitLedgerTypeFilter['equals'];
const where: Prisma.CommonEventWhereInput = {
eventType: 'BENEFIT_LEDGER',
...(query.userId ? { actorType: 'USER', actorId: BigInt(query.userId) } : {}),
...(query.couponId ? { param2: BigInt(query.couponId).toString() } : {}),
...(query.type ? { param1: query.type } : {}),
};
const [items, total] = await Promise.all([
this.prisma.benefitLedger.findMany({
this.prisma.commonEvent.findMany({
where,
orderBy: { createdAt: 'desc' },
skip: (page - 1) * pageSize,
take: pageSize,
include: {
user: { select: { id: true, userNo: true } },
coupon: { select: { id: true, couponNo: true } },
},
}),
this.prisma.benefitLedger.count({ where }),
this.prisma.commonEvent.count({ where }),
]);
return serializeBigInt({ items, total, page, pageSize });
const userIds = [...new Set(items.map((i) => i.actorId).filter(Boolean))] as bigint[];
const couponIds = [...new Set(items.map((i) => i.param2).filter(Boolean))].map((id) => BigInt(id!));
const [users, coupons] = await Promise.all([
userIds.length
? this.prisma.user.findMany({ where: { id: { in: userIds } }, select: { id: true, userNo: true } })
: Promise.resolve([] as { id: bigint; userNo: string | null }[]),
couponIds.length
? this.prisma.benefitCoupon.findMany({ where: { id: { in: couponIds } }, select: { id: true, couponNo: true } })
: Promise.resolve([] as { id: bigint; couponNo: string }[]),
]);
const userMap = new Map(users.map((u) => [u.id.toString(), u] as const));
const couponMap = new Map(coupons.map((c) => [c.id.toString(), c] as const));
return serializeBigInt({
items: items.map((e) =>
mapBenefitLedgerCompat(
e,
e.actorId ? userMap.get(e.actorId.toString()) : null,
e.param2 ? couponMap.get(e.param2) : null,
),
),
total,
page,
pageSize,
});
}
}
@@ -12,14 +12,14 @@ export class AdminCitiesService {
async list(query: AdminCitiesQueryDto) {
const page = query.page ?? 1;
const pageSize = query.pageSize ?? 20;
const where: Prisma.CityWhereInput = {};
const where: Prisma.CommonCityWhereInput = {};
if (query.name) where.name = { contains: query.name };
if (query.code) where.code = { contains: query.code };
if (query.status) where.status = query.status as Prisma.EnumCityStatusFilter['equals'];
if (query.partnerId) where.partnerId = BigInt(query.partnerId);
const [items, total] = await Promise.all([
this.prisma.city.findMany({
this.prisma.commonCity.findMany({
where,
orderBy: { createdAt: 'desc' },
skip: (page - 1) * pageSize,
@@ -29,7 +29,7 @@ export class AdminCitiesService {
_count: { select: { stores: true, orders: true } },
},
}),
this.prisma.city.count({ where }),
this.prisma.commonCity.count({ where }),
]);
return serializeBigInt({
items: items.map((c) => ({
@@ -45,7 +45,7 @@ export class AdminCitiesService {
}
async detail(id: bigint) {
const city = await this.prisma.city.findUnique({
const city = await this.prisma.commonCity.findUnique({
where: { id },
include: {
partner: true,
@@ -58,9 +58,9 @@ export class AdminCitiesService {
}
async create(dto: CreateCityDto) {
const exists = await this.prisma.city.findUnique({ where: { code: dto.code } });
const exists = await this.prisma.commonCity.findUnique({ where: { code: dto.code } });
if (exists) throw new BadRequestException('城市编码已存在');
const city = await this.prisma.city.create({
const city = await this.prisma.commonCity.create({
data: {
code: dto.code,
name: dto.name,
@@ -79,7 +79,7 @@ export class AdminCitiesService {
}
async update(id: bigint, dto: UpdateCityDto) {
const city = await this.prisma.city.update({
const city = await this.prisma.commonCity.update({
where: { id },
data: {
...(dto.name !== undefined ? { name: dto.name } : {}),
@@ -2,6 +2,8 @@ import { Injectable, NotFoundException } from '@nestjs/common';
import { Prisma } from '@prisma/client';
import { PrismaService } from '../../common/prisma/prisma.module';
import { serializeBigInt } from '../../common/decorators/current-user.decorator';
import { orderStatusLogWhere } from '../../common/event/event.helpers';
import { mapOrderCompat, mapStatusLogCompat } from '../../common/compat/v31-compat';
import type { AdminOrdersQueryDto } from './dto/admin-query.dto';
@Injectable()
@@ -54,17 +56,24 @@ export class AdminOrdersService {
phoneVerifiedAt: true,
},
},
items: true,
delivery: true,
payment: true,
statusLogs: { orderBy: { createdAt: 'asc' } },
benefitCoupons: {
benefitCoupon: {
select: { id: true, couponNo: true, balance: true, status: true },
},
city: { select: { id: true, name: true, code: true } },
product: { select: { id: true, name: true, skuCode: true, barcode69: true } },
imageResource: { select: { id: true, url: true } },
},
});
if (!order) throw new NotFoundException('订单不存在');
return serializeBigInt(order);
const statusLogs = await this.prisma.commonEvent.findMany({
where: orderStatusLogWhere(id),
orderBy: { createdAt: 'asc' },
});
return serializeBigInt(mapOrderCompat({
...order,
statusLogs: mapStatusLogCompat(statusLogs),
benefitCoupons: order.benefitCoupon ? [order.benefitCoupon] : [],
}));
}
}
@@ -0,0 +1,31 @@
import { Body, Controller, Get, Param, Post, Put, Query, UseGuards } from '@nestjs/common';
import { HqAuthGuard } from '../../common/guards/hq-auth.guard';
import { AdminProductsService } from './admin-products.service';
import { AdminProductsQueryDto } from './dto/admin-query.dto';
import { CreateProductDto, UpdateProductDto } from './dto/admin-mutate.dto';
@Controller('admin/products')
@UseGuards(HqAuthGuard)
export class AdminProductsController {
constructor(private readonly service: AdminProductsService) {}
@Get()
list(@Query() query: AdminProductsQueryDto) {
return this.service.list(query);
}
@Get(':id')
detail(@Param('id') id: string) {
return this.service.detail(BigInt(id));
}
@Post()
create(@Body() dto: CreateProductDto) {
return this.service.create(dto);
}
@Put(':id')
update(@Param('id') id: string, @Body() dto: UpdateProductDto) {
return this.service.update(BigInt(id), dto);
}
}
@@ -0,0 +1,135 @@
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
import { Prisma } from '@prisma/client';
import { PrismaService } from '../../common/prisma/prisma.module';
import { serializeBigInt } from '../../common/decorators/current-user.decorator';
import type { AdminProductsQueryDto } from './dto/admin-query.dto';
import type { CreateProductDto, UpdateProductDto } from './dto/admin-mutate.dto';
@Injectable()
export class AdminProductsService {
constructor(private readonly prisma: PrismaService) {}
async list(query: AdminProductsQueryDto) {
const page = query.page ?? 1;
const pageSize = query.pageSize ?? 20;
const where: Prisma.CommonProductItemWhereInput = {};
if (query.name) where.name = { contains: query.name };
if (query.status) where.status = query.status as Prisma.EnumProductStatusFilter['equals'];
if (query.aromaType) where.aromaType = query.aromaType as Prisma.EnumAromaTypeFilter['equals'];
const [items, total] = await Promise.all([
this.prisma.commonProductItem.findMany({
where,
orderBy: { sortOrder: 'asc' },
skip: (page - 1) * pageSize,
take: pageSize,
include: { coverResource: true },
}),
this.prisma.commonProductItem.count({ where }),
]);
return serializeBigInt({
items: items.map((p) => ({
...p,
mainImageUrl: p.coverResource?.url ?? null,
})),
total,
page,
pageSize,
});
}
async detail(id: bigint) {
const product = await this.prisma.commonProductItem.findUnique({
where: { id },
include: { coverResource: true },
});
if (!product) throw new NotFoundException('商品不存在');
return serializeBigInt({ ...product, mainImageUrl: product.coverResource?.url ?? null });
}
async create(dto: CreateProductDto) {
const exists = await this.prisma.commonProductItem.findFirst({
where: { OR: [{ skuCode: dto.skuCode }, { barcode69: dto.barcode69 }] },
});
if (exists) throw new BadRequestException('SKU 或 69 码已存在');
const product = await this.prisma.commonProductItem.create({
data: {
skuCode: dto.skuCode,
barcode69: dto.barcode69,
name: dto.name,
subtitle: dto.subtitle,
aromaType: dto.aromaType as 'QINGXIANG' | 'JIANGXIANG' | 'NONGXIANG',
spec: dto.spec,
price: dto.price,
benefitAmount: dto.benefitAmount ?? dto.price,
status: (dto.status ?? 'DRAFT') as 'DRAFT' | 'ON_SALE' | 'OFF_SALE',
sortOrder: dto.sortOrder ?? 0,
},
});
if (dto.coverUrl) {
const cover = await this.prisma.commonResource.create({
data: {
ownerType: 'PRODUCT',
ownerId: product.id,
bizType: 'COVER',
mediaType: 'IMAGE',
ossBucket: 'legacy',
ossKey: dto.coverUrl,
url: dto.coverUrl,
},
});
await this.prisma.commonProductItem.update({
where: { id: product.id },
data: { coverResourceId: cover.id },
});
}
return this.detail(product.id);
}
async update(id: bigint, dto: UpdateProductDto) {
await this.detail(id);
await this.prisma.commonProductItem.update({
where: { id },
data: {
...(dto.name !== undefined ? { name: dto.name } : {}),
...(dto.subtitle !== undefined ? { subtitle: dto.subtitle } : {}),
...(dto.spec !== undefined ? { spec: dto.spec } : {}),
...(dto.price !== undefined ? { price: dto.price } : {}),
...(dto.benefitAmount !== undefined ? { benefitAmount: dto.benefitAmount } : {}),
...(dto.status !== undefined ? { status: dto.status as 'DRAFT' | 'ON_SALE' | 'OFF_SALE' } : {}),
...(dto.sortOrder !== undefined ? { sortOrder: dto.sortOrder } : {}),
},
});
if (dto.coverUrl) {
const product = await this.prisma.commonProductItem.findUniqueOrThrow({ where: { id } });
if (product.coverResourceId) {
await this.prisma.commonResource.update({
where: { id: product.coverResourceId },
data: { url: dto.coverUrl, ossKey: dto.coverUrl },
});
} else {
const cover = await this.prisma.commonResource.create({
data: {
ownerType: 'PRODUCT',
ownerId: id,
bizType: 'COVER',
mediaType: 'IMAGE',
ossBucket: 'legacy',
ossKey: dto.coverUrl,
url: dto.coverUrl,
},
});
await this.prisma.commonProductItem.update({
where: { id },
data: { coverResourceId: cover.id },
});
}
}
return this.detail(id);
}
}
@@ -1,4 +1,5 @@
import { Injectable, NotFoundException } from '@nestjs/common';
import type { DeliveryProvider } from '@prisma/client';
import { Prisma } from '@prisma/client';
import { PrismaService } from '../../common/prisma/prisma.module';
import { serializeBigInt } from '../../common/decorators/current-user.decorator';
@@ -42,7 +43,6 @@ export class AdminRedeemService {
store: { include: { partner: { select: { id: true, companyName: true } } } },
coupon: true,
payout: true,
commissions: true,
},
});
if (!record) throw new NotFoundException('核销记录不存在');
@@ -58,7 +58,7 @@ export class AdminDeliveriesService {
const page = query.page ?? 1;
const pageSize = query.pageSize ?? 20;
const where: Prisma.OrderDeliveryWhereInput = {};
if (query.provider) where.provider = query.provider;
if (query.provider) where.provider = query.provider as DeliveryProvider;
if (query.trackingNo) where.trackingNo = { contains: query.trackingNo };
if (query.orderNo) {
where.order = { orderNo: { contains: query.orderNo } };
@@ -79,6 +79,8 @@ export class AdminDeliveriesService {
receiverName: true,
receiverPhone: true,
deliveryType: true,
productName: true,
quantity: true,
},
},
},
@@ -95,7 +97,7 @@ export class AdminDeliveriesService {
order: {
include: {
user: { select: { id: true, userNo: true, phone: true } },
items: true,
imageResource: { select: { url: true } },
},
},
},
@@ -108,7 +110,7 @@ export class AdminDeliveriesService {
const delivery = await this.prisma.orderDelivery.update({
where: { id },
data: {
...(dto.provider !== undefined ? { provider: dto.provider } : {}),
...(dto.provider !== undefined ? { provider: dto.provider as DeliveryProvider } : {}),
...(dto.providerOrderNo !== undefined ? { providerOrderNo: dto.providerOrderNo } : {}),
...(dto.trackingNo !== undefined ? { trackingNo: dto.trackingNo } : {}),
},
@@ -2,6 +2,7 @@ import { BadRequestException, Injectable, NotFoundException } from '@nestjs/comm
import { Prisma } from '@prisma/client';
import { PrismaService } from '../../common/prisma/prisma.module';
import { serializeBigInt } from '../../common/decorators/current-user.decorator';
import { mapStoreCompat } from '../../common/compat/v31-compat';
import type { AdminStoreAccountsQueryDto, AdminStoreMediaQueryDto, AdminStoresQueryDto } from './dto/admin-query.dto';
import type {
CreateStoreAccountDto,
@@ -37,11 +38,17 @@ export class AdminStoresService {
cityRef: { select: { id: true, name: true, code: true } },
partner: { select: { id: true, companyName: true } },
account: { select: { id: true, phone: true, name: true, status: true } },
coverResource: { select: { id: true, url: true } },
},
}),
this.prisma.store.count({ where }),
]);
return serializeBigInt({ items, total, page, pageSize });
return serializeBigInt({
items: items.map((s) => mapStoreCompat(s)),
total,
page,
pageSize,
});
}
async detailStore(id: bigint) {
@@ -52,18 +59,30 @@ export class AdminStoresService {
partner: true,
category: true,
account: true,
media: { orderBy: { sortOrder: 'asc' } },
audits: { orderBy: { submittedAt: 'desc' }, take: 5 },
coverResource: true,
_count: { select: { redeemRecords: true, ratings: true } },
},
});
if (!store) throw new NotFoundException('门店不存在');
return serializeBigInt({
const [media, audits] = await Promise.all([
this.prisma.commonResource.findMany({
where: { ownerType: 'STORE', ownerId: id, status: 'ACTIVE' },
orderBy: { sortOrder: 'asc' },
}),
this.prisma.commonEvent.findMany({
where: { eventType: 'STORE_AUDIT', refType: 'STORE', refId: id },
orderBy: { createdAt: 'desc' },
take: 5,
}),
]);
return serializeBigInt(mapStoreCompat({
...store,
media,
audits,
redeemCount: store._count.redeemRecords,
ratingCount: store._count.ratings,
_count: undefined,
});
}));
}
async updateStoreStatus(id: bigint, dto: UpdateStoreStatusDto) {
@@ -81,18 +100,41 @@ export class AdminStoresService {
...(dto.name !== undefined ? { name: dto.name } : {}),
...(dto.phone !== undefined ? { phone: dto.phone } : {}),
...(dto.intro !== undefined ? { intro: dto.intro } : {}),
...(dto.coverUrl !== undefined ? { coverUrl: dto.coverUrl } : {}),
...(dto.address !== undefined ? { address: dto.address } : {}),
...(dto.district !== undefined ? { district: dto.district } : {}),
},
});
return serializeBigInt(store);
if (dto.coverUrl) {
const current = await this.prisma.store.findUniqueOrThrow({ where: { id } });
if (current.coverResourceId) {
await this.prisma.commonResource.update({
where: { id: current.coverResourceId },
data: { url: dto.coverUrl, ossKey: dto.coverUrl },
});
} else {
const cover = await this.prisma.commonResource.create({
data: {
ownerType: 'STORE',
ownerId: id,
bizType: 'COVER',
mediaType: 'IMAGE',
ossBucket: 'legacy',
ossKey: dto.coverUrl,
url: dto.coverUrl,
},
});
await this.prisma.store.update({ where: { id }, data: { coverResourceId: cover.id } });
}
}
return this.detailStore(id);
}
async createStore(dto: CreateStoreDto) {
const partner = await this.prisma.partner.findUnique({ where: { id: BigInt(dto.partnerId) } });
if (!partner) throw new BadRequestException('开城合伙人不存在');
const city = await this.prisma.city.findUnique({ where: { id: BigInt(dto.cityId) } });
const city = await this.prisma.commonCity.findUnique({ where: { id: BigInt(dto.cityId) } });
if (!city) throw new BadRequestException('开城城市不存在');
const store = await this.prisma.store.create({
@@ -107,7 +149,6 @@ export class AdminStoresService {
district: dto.district ?? '',
address: dto.address,
intro: dto.intro ?? null,
coverUrl: dto.coverUrl ?? null,
status: 'OPEN',
},
});
@@ -139,19 +180,21 @@ export class AdminStoresService {
async listStoreMedia(query: AdminStoreMediaQueryDto) {
const page = query.page ?? 1;
const pageSize = query.pageSize ?? 20;
const where: Prisma.StoreMediaWhereInput = {};
if (query.storeId) where.storeId = BigInt(query.storeId);
if (query.mediaType) where.mediaType = query.mediaType;
const where: Prisma.CommonResourceWhereInput = {
ownerType: 'STORE',
status: 'ACTIVE',
};
if (query.storeId) where.ownerId = BigInt(query.storeId);
if (query.mediaType) where.mediaType = query.mediaType as Prisma.EnumResourceMediaTypeFilter['equals'];
const [items, total] = await Promise.all([
this.prisma.storeMedia.findMany({
this.prisma.commonResource.findMany({
where,
orderBy: [{ sortOrder: 'asc' }, { createdAt: 'desc' }],
skip: (page - 1) * pageSize,
take: pageSize,
include: { store: { select: { id: true, name: true } } },
}),
this.prisma.storeMedia.count({ where }),
this.prisma.commonResource.count({ where }),
]);
return serializeBigInt({ items, total, page, pageSize });
}
@@ -159,10 +202,14 @@ export class AdminStoresService {
async createStoreMedia(dto: CreateStoreMediaDto) {
const store = await this.prisma.store.findUnique({ where: { id: BigInt(dto.storeId) } });
if (!store) throw new BadRequestException('门店不存在');
const media = await this.prisma.storeMedia.create({
const media = await this.prisma.commonResource.create({
data: {
storeId: store.id,
mediaType: dto.mediaType,
ownerType: 'STORE',
ownerId: store.id,
bizType: 'ENV',
mediaType: dto.mediaType as 'IMAGE' | 'VIDEO',
ossBucket: 'legacy',
ossKey: dto.url,
url: dto.url,
sortOrder: dto.sortOrder ?? 0,
},
@@ -171,11 +218,11 @@ export class AdminStoresService {
}
async updateStoreMedia(id: bigint, dto: UpdateStoreMediaDto) {
const media = await this.prisma.storeMedia.update({
const media = await this.prisma.commonResource.update({
where: { id },
data: {
...(dto.url !== undefined ? { url: dto.url } : {}),
...(dto.mediaType !== undefined ? { mediaType: dto.mediaType } : {}),
...(dto.url !== undefined ? { url: dto.url, ossKey: dto.url } : {}),
...(dto.mediaType !== undefined ? { mediaType: dto.mediaType as 'IMAGE' | 'VIDEO' } : {}),
...(dto.sortOrder !== undefined ? { sortOrder: dto.sortOrder } : {}),
},
});
@@ -183,7 +230,10 @@ export class AdminStoresService {
}
async deleteStoreMedia(id: bigint) {
await this.prisma.storeMedia.delete({ where: { id } });
await this.prisma.commonResource.update({
where: { id },
data: { status: 'DELETED' },
});
return { ok: true };
}
@@ -59,7 +59,7 @@ export class AdminUsersService {
const user = await this.prisma.user.findUnique({
where: { id },
include: {
cityPref: true,
cityPreference: true,
mergedInto: { select: { id: true, userNo: true, phone: true, nickname: true } },
orders: {
orderBy: { createdAt: 'desc' },
@@ -1,4 +1,4 @@
import { IsIn, IsNotEmpty, IsOptional, IsString } from 'class-validator';
import { IsIn, IsNotEmpty, IsNumber, IsOptional, IsString } from 'class-validator';
export class UpdateStoreStatusDto {
@IsString()
@@ -311,3 +311,81 @@ export class UpdateHqAccountDto {
@IsIn(['ACTIVE', 'DISABLED'])
status?: string;
}
export class CreateProductDto {
@IsString()
@IsNotEmpty()
skuCode: string;
@IsString()
@IsNotEmpty()
barcode69: string;
@IsString()
@IsNotEmpty()
name: string;
@IsOptional()
@IsString()
subtitle?: string;
@IsIn(['QINGXIANG', 'JIANGXIANG', 'NONGXIANG'])
aromaType: string;
@IsString()
@IsNotEmpty()
spec: string;
@IsNumber()
price: number;
@IsOptional()
@IsNumber()
benefitAmount?: number;
@IsOptional()
@IsIn(['DRAFT', 'ON_SALE', 'OFF_SALE'])
status?: string;
@IsOptional()
@IsNumber()
sortOrder?: number;
@IsOptional()
@IsString()
coverUrl?: string;
}
export class UpdateProductDto {
@IsOptional()
@IsString()
name?: string;
@IsOptional()
@IsString()
subtitle?: string;
@IsOptional()
@IsString()
spec?: string;
@IsOptional()
@IsNumber()
price?: number;
@IsOptional()
@IsNumber()
benefitAmount?: number;
@IsOptional()
@IsIn(['DRAFT', 'ON_SALE', 'OFF_SALE'])
status?: string;
@IsOptional()
@IsNumber()
sortOrder?: number;
@IsOptional()
@IsString()
coverUrl?: string;
}
@@ -213,6 +213,20 @@ export class AdminCitiesQueryDto extends PaginationQueryDto {
partnerId?: string;
}
export class AdminProductsQueryDto extends PaginationQueryDto {
@IsOptional()
@IsString()
name?: string;
@IsOptional()
@IsString()
status?: string;
@IsOptional()
@IsString()
aromaType?: string;
}
export class AdminStoreMediaQueryDto extends PaginationQueryDto {
@IsOptional()
@IsString()
@@ -18,6 +18,8 @@ import { AdminRedeemRecordsController, AdminDeliveriesController } from './admin
import { AdminRedeemService, AdminDeliveriesService } from './admin-redeem.service';
import { AdminHqAccountsController } from './admin-hq-accounts.controller';
import { AdminHqAccountsService } from './admin-hq-accounts.service';
import { AdminProductsController } from './admin-products.controller';
import { AdminProductsService } from './admin-products.service';
import { SuperAdminGuard } from '../../common/guards/super-admin.guard';
@Module({
@@ -37,6 +39,7 @@ import { SuperAdminGuard } from '../../common/guards/super-admin.guard';
AdminRedeemRecordsController,
AdminDeliveriesController,
AdminHqAccountsController,
AdminProductsController,
],
providers: [
AdminDashboardService,
@@ -49,6 +52,7 @@ import { SuperAdminGuard } from '../../common/guards/super-admin.guard';
AdminRedeemService,
AdminDeliveriesService,
AdminHqAccountsService,
AdminProductsService,
SuperAdminGuard,
],
})
@@ -15,6 +15,7 @@ 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';
import { buildBenefitLedgerEvent } from '../../common/event/event.helpers';
@Injectable()
export class RedeemService {
@@ -57,17 +58,6 @@ export class RedeemService {
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}`,
{
@@ -130,7 +120,7 @@ export class RedeemService {
}
const amount = Number(cached.amount);
const cityRule = await this.prisma.cityCommissionRule.findFirst({
const cityRule = await this.prisma.commonCityCommissionRule.findFirst({
where: { city: { stores: { some: { id: account.storeId } } } },
});
const settlementRate = cityRule ? Number(cityRule.storeSettlementRate) : 0.6;
@@ -154,8 +144,8 @@ export class RedeemService {
if (updated.count === 0) throw new BadRequestException('核销失败,请重试');
const newBalance = Number(coupon.balance) - allocAmount;
await tx.benefitLedger.create({
data: {
await tx.commonEvent.create({
data: buildBenefitLedgerEvent({
userId: coupon.userId,
couponId: coupon.id,
type: 'REDEEM',
@@ -163,7 +153,7 @@ export class RedeemService {
balanceAfter: newBalance,
refType: 'STORE',
refId: account.storeId,
},
}),
});
}
@@ -178,11 +168,6 @@ export class RedeemService {
},
});
await tx.redeemToken.updateMany({
where: { token: body.token },
data: { status: 'USED', usedAt: new Date(), storeId: account.storeId },
});
return redeemRecord;
});
@@ -6,6 +6,7 @@ import {
import { loadAppConfig } from '@dukang/shared-types';
import { PrismaService } from '../../common/prisma/prisma.module';
import { serializeBigInt } from '../../common/decorators/current-user.decorator';
import { mapStoreCompat } from '../../common/compat/v31-compat';
@Injectable()
export class StoreService {
@@ -16,39 +17,43 @@ export class StoreService {
async listOpenStores(cityCode?: string) {
const where: Record<string, unknown> = { status: 'OPEN' };
if (cityCode) {
const city = await this.prisma.city.findFirst({ where: { code: cityCode } });
const city = await this.prisma.commonCity.findFirst({ where: { code: cityCode } });
if (city) where.cityId = city.id;
}
const stores = await this.prisma.store.findMany({
where: where as never,
include: { category: true },
include: { category: true, coverResource: true },
orderBy: { createdAt: 'desc' },
});
return serializeBigInt(stores);
return serializeBigInt(stores.map(mapStoreCompat));
}
async getStore(id: bigint) {
const store = await this.prisma.store.findFirst({
where: { id, status: 'OPEN' },
include: { category: true, media: true },
include: { category: true, coverResource: true },
});
if (!store) throw new NotFoundException('门店不存在');
return serializeBigInt(store);
const media = await this.prisma.commonResource.findMany({
where: { ownerType: 'STORE', ownerId: id, status: 'ACTIVE', bizType: 'ENV' },
orderBy: { sortOrder: 'asc' },
});
return serializeBigInt(mapStoreCompat({ ...store, media }));
}
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 } },
include: { category: true, coverResource: true },
orderBy: { createdAt: 'desc' },
});
return serializeBigInt(stores);
return serializeBigInt(stores.map(mapStoreCompat));
}
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 } });
const city = await this.prisma.commonCity.findFirst({ where: { partnerId: account.partnerId } });
if (!city) throw new BadRequestException('合伙人未绑定开城');
const store = await this.prisma.store.create({
@@ -63,7 +68,7 @@ export class StoreService {
district: String(body.district ?? ''),
address: String(body.address),
intro: body.intro ? String(body.intro) : null,
coverUrl: body.coverUrl ? String(body.coverUrl) : null,
coverResourceId: body.coverResourceId ? BigInt(String(body.coverResourceId)) : null,
bankAccountName: body.bankAccountName ? String(body.bankAccountName) : null,
bankAccountNo: body.bankAccountNo ? String(body.bankAccountNo) : null,
bankBranch: body.bankBranch ? String(body.bankBranch) : null,
@@ -73,13 +78,17 @@ export class StoreService {
},
});
const audit = await this.prisma.storeAudit.create({
const audit = await this.prisma.commonEvent.create({
data: {
storeId: store.id,
auditType: 'NEW',
eventType: 'STORE_AUDIT',
refType: 'STORE',
refId: store.id,
actorType: 'PARTNER',
actorId: partnerAccountId,
status: this.config.autoApproveStore ? 'APPROVED' : 'PENDING',
submitData: body as never,
reviewedAt: this.config.autoApproveStore ? new Date() : null,
param1: 'NEW',
param1Desc: 'audit_type',
extraJson: body as never,
},
});
@@ -97,9 +106,9 @@ export class StoreService {
async getShopStore(storeAccountId: bigint) {
const account = await this.prisma.storeAccount.findUniqueOrThrow({
where: { id: storeAccountId },
include: { store: { include: { category: true } } },
include: { store: { include: { category: true, coverResource: true } } },
});
return serializeBigInt(account.store);
return serializeBigInt(mapStoreCompat(account.store));
}
async updateShopStatus(storeAccountId: bigint, status: 'OPEN' | 'PAUSED') {
@@ -4,6 +4,7 @@ import {
Injectable,
NotFoundException,
} from '@nestjs/common';
import type { FreightPayType } from '@prisma/client';
import {
calcBenefitAmount,
generateOrderNo,
@@ -19,6 +20,8 @@ import { IDeliveryProvider } from '../../integrations/delivery/delivery.interfac
import { IpGeoService } from '../../common/geo/ip-geo.service';
import { buildOrderClientLocationSnapshot } from '../../common/geo/client-location.util';
import { extractClientIp } from '../../common/geo/client-ip.util';
import { buildOrderStatusEvent, orderStatusLogWhere } from '../../common/event/event.helpers';
import { mapOrderCompat, mapStatusLogCompat } from '../../common/compat/v31-compat';
import type { Request } from 'express';
@Injectable()
@@ -32,13 +35,13 @@ export class TradeService {
) {}
async preview(userId: bigint, body: { productId: string; quantity: number; addressId?: string }) {
const product = await this.prisma.product.findUnique({
const product = await this.prisma.commonProductItem.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' } });
const city = await this.prisma.commonCity.findFirst({ where: { status: 'ACTIVE' } });
if (!city) throw new BadRequestException('暂无开城城市');
let deliveryType: 'LOCAL' | 'CROSS_CITY' = 'LOCAL';
@@ -66,13 +69,15 @@ export class TradeService {
benefitAmount: product.benefitAmount ? Number(product.benefitAmount) : null,
});
const freightPayType: FreightPayType | null = deliveryType === 'CROSS_CITY' ? 'COD' : null;
return {
product: serializeBigInt(product),
quantity: body.quantity,
deliveryType,
productAmount,
freightAmount: deliveryType === 'CROSS_CITY' ? 0 : 0,
freightPayType: deliveryType === 'CROSS_CITY' ? 'COD' : null,
freightPayType,
payAmount: productAmount,
benefitAmount: benefitPerUnit * body.quantity,
city: serializeBigInt(city),
@@ -95,10 +100,10 @@ export class TradeService {
});
if (!address) throw new BadRequestException('请选择收货地址');
const product = await this.prisma.product.findUniqueOrThrow({
const product = await this.prisma.commonProductItem.findUniqueOrThrow({
where: { id: BigInt(body.productId) },
});
const city = await this.prisma.city.findFirstOrThrow({ where: { status: 'ACTIVE' } });
const city = await this.prisma.commonCity.findFirstOrThrow({ where: { status: 'ACTIVE' } });
const orderNo = generateOrderNo();
const payExpireAt = new Date(Date.now() + 30 * 60 * 1000);
@@ -114,7 +119,17 @@ export class TradeService {
userId,
cityId: city.id,
status: 'PENDING_PAY',
payStatus: 'UNPAID',
deliveryType: preview.deliveryType as 'LOCAL' | 'CROSS_CITY',
productId: product.id,
barcode69: product.barcode69,
productName: product.name,
productSpec: product.spec,
imageResourceId: product.coverResourceId,
quantity: body.quantity,
listUnitPrice: product.price,
listAmount: preview.productAmount,
productAmount: preview.productAmount,
receiverName: address.receiverName,
receiverPhone: address.phone,
receiverAddress: `${address.province}${address.city}${address.district}${address.detail}`,
@@ -131,41 +146,21 @@ export class TradeService {
gpsLatitude: location.gpsLatitude,
gpsLongitude: location.gpsLongitude,
gpsAddress: location.gpsAddress,
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 },
include: { product: true, imageResource: true },
});
return serializeBigInt(order);
return serializeBigInt(mapOrderCompat(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') {
@@ -176,28 +171,36 @@ export class TradeService {
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: {
status: 'PENDING_SHIP',
payStatus: 'PAID',
paidAt: now,
payExternalNo: externalNo,
},
});
await tx.logThirdParty.create({
data: {
provider: 'WECHAT_PAY',
scene: 'ORDER_PAY',
refType: 'ORDER',
refId: order.id,
externalNo,
amount: order.payAmount,
status: 'SUCCESS',
},
});
await tx.commonEvent.create({
data: buildOrderStatusEvent({
orderId: order.id,
fromStatus: 'PENDING_PAY',
toStatus: 'PENDING_SHIP',
operator: 'MOCK_PAY',
},
}),
});
await tx.orderDelivery.create({
data: { orderId: order.id, provider: 'MOCK' },
data: { orderId: order.id, provider: 'MANUAL' },
});
});
@@ -216,29 +219,32 @@ export class TradeService {
const [list, total] = await Promise.all([
this.prisma.order.findMany({
where,
include: { items: true, benefitCoupons: true },
include: { benefitCoupon: true, imageResource: true },
orderBy: { createdAt: 'desc' },
skip: (page - 1) * pageSize,
take: pageSize,
}),
this.prisma.order.count({ where }),
]);
return { list: serializeBigInt(list), total, page, pageSize };
return { list: serializeBigInt(list.map(mapOrderCompat)), 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' } },
benefitCoupon: true,
imageResource: true,
product: true,
},
});
if (!order) throw new NotFoundException('订单不存在');
return serializeBigInt(order);
const statusLogs = await this.prisma.commonEvent.findMany({
where: orderStatusLogWhere(orderId),
orderBy: { createdAt: 'desc' },
});
return serializeBigInt(mapOrderCompat({ ...order, statusLogs: mapStatusLogCompat(statusLogs) }));
}
async updateAddress(userId: bigint, orderId: bigint, body: Record<string, unknown>) {
@@ -258,14 +264,14 @@ export class TradeService {
receiverAddress: String(body.receiverAddress ?? order.receiverAddress),
},
});
await this.prisma.orderStatusLog.create({
data: {
await this.prisma.commonEvent.create({
data: buildOrderStatusEvent({
orderId,
fromStatus: order.status,
toStatus: order.status,
operator: 'USER',
remark: '修改收货地址',
},
}),
});
return serializeBigInt(updated);
}
@@ -284,33 +290,37 @@ export class TradeService {
const account = await this.prisma.partnerAccount.findUniqueOrThrow({
where: { id: partnerAccountId },
});
const cities = await this.prisma.city.findMany({ where: { partnerId: account.partnerId } });
const cities = await this.prisma.commonCity.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 } } },
include: { delivery: true, imageResource: 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 };
return { list: serializeBigInt(list.map(mapOrderCompat)), 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 cities = await this.prisma.commonCity.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 },
include: { delivery: true, user: true, imageResource: true },
});
if (!order) throw new NotFoundException('订单不存在');
return serializeBigInt(order);
const statusLogs = await this.prisma.commonEvent.findMany({
where: orderStatusLogWhere(orderId),
orderBy: { createdAt: 'desc' },
});
return serializeBigInt(mapOrderCompat({ ...order, statusLogs: mapStatusLogCompat(statusLogs) }));
}
async advanceDelivery(partnerAccountId: bigint, orderId: bigint, targetStatus: string) {
@@ -357,13 +367,13 @@ export class TradeService {
if (Object.keys(deliveryData).length) {
await tx.orderDelivery.update({ where: { orderId }, data: deliveryData as never });
}
await tx.orderStatusLog.create({
data: {
await tx.commonEvent.create({
data: buildOrderStatusEvent({
orderId,
fromStatus: currentStatus,
toStatus: targetStatus,
operator,
},
}),
});
});
}