From 16e625aefb54a707387241eab5615ef9dbd1cfe7 Mon Sep 17 00:00:00 2001 From: lhmin0604 Date: Sun, 13 Sep 2026 13:56:59 +0800 Subject: [PATCH] feat(r3): complete order and payment flow --- docs/api-contract-v1.md | 15 +- package.json | 2 +- .../migration.sql | 19 +++ .../migration.sql | 11 ++ prisma/schema.prisma | 18 +++ prisma/seed.js | 20 +++ prisma/seed.ts | 48 +++--- src/auth/auth.controller.ts | 13 +- src/auth/auth.service.ts | 17 +- src/design-list/design-list.service.ts | 11 +- src/orders/dto/create-order.dto.ts | 31 ++-- src/orders/dto/order-query.dto.ts | 11 ++ src/orders/orders.controller.ts | 23 ++- src/orders/orders.service.ts | 148 ++++++++++++++---- src/payments/payments.controller.ts | 5 +- src/payments/payments.service.ts | 33 +++- src/products/dto/create-product.dto.ts | 56 ++++++- src/products/dto/product-query.dto.ts | 15 ++ src/products/products.controller.ts | 7 +- src/products/products.service.ts | 77 +++++++-- 20 files changed, 454 insertions(+), 126 deletions(-) create mode 100644 prisma/migrations/20260912130000_r1_r3_contract/migration.sql create mode 100644 prisma/migrations/20260913113000_payment_timeout/migration.sql create mode 100644 prisma/seed.js create mode 100644 src/orders/dto/order-query.dto.ts create mode 100644 src/products/dto/product-query.dto.ts diff --git a/docs/api-contract-v1.md b/docs/api-contract-v1.md index 1b997ff..27f12b3 100644 --- a/docs/api-contract-v1.md +++ b/docs/api-contract-v1.md @@ -35,15 +35,10 @@ ```json { - "accessToken": "jwt", - "isNewUser": true, - "nickname": null, - "avatar": null + "accessToken": "jwt" } ``` -`isNewUser=true` 时前端引导补全昵称/头像。 - ### POST /api/auth/register 公开接口。企业主体可选:换手机号注册,返回 `{ accessToken }`。 @@ -56,7 +51,7 @@ 更新当前用户资料。请求体:`{ nickname?: string, avatar?: string }`。 -## 3. 商品与分类(R1,待实现) +## 3. 商品与分类(R1,已实现) ### GET /api/categories @@ -104,7 +99,7 @@ } ``` -## 4. 收货地址(R2,待实现完整 CRUD) +## 4. 收货地址(R2,已实现 CRUD) ### GET /api/addresses @@ -143,7 +138,7 @@ 注意:前端 `AddressItem.region: string[]` 与后端 `province/city/district` 的转换 只允许出现在前端 `src/utils/api/address.ts`。 -## 5. 设计清单(R2,待实现完整 CRUD) +## 5. 设计清单(R2,已实现 CRUD) ### GET /api/design-list @@ -212,7 +207,7 @@ `ordered` 由 `orderId != null` 派生,前端不提交该状态。 -## 6. 订单(R3,待实现) +## 6. 订单(R3,已实现) ### POST /api/orders diff --git a/package.json b/package.json index 82ec5d3..165c2fb 100644 --- a/package.json +++ b/package.json @@ -60,7 +60,7 @@ "typescript": "^5.6.0" }, "prisma": { - "seed": "ts-node prisma/seed.ts" + "seed": "node prisma/seed.js" }, "engines": { "node": ">=22" diff --git a/prisma/migrations/20260912130000_r1_r3_contract/migration.sql b/prisma/migrations/20260912130000_r1_r3_contract/migration.sql new file mode 100644 index 0000000..3d2bd53 --- /dev/null +++ b/prisma/migrations/20260912130000_r1_r3_contract/migration.sql @@ -0,0 +1,19 @@ +-- R1 product fields and R3 order idempotency fields. +ALTER TABLE "Product" + ADD COLUMN IF NOT EXISTS "originalPrice" DECIMAL(10,2), + ADD COLUMN IF NOT EXISTS "leadTime" TEXT NOT NULL DEFAULT '7-10 个工作日', + ADD COLUMN IF NOT EXISTS "subtitle" TEXT, + ADD COLUMN IF NOT EXISTS "tone" INTEGER[] NOT NULL DEFAULT ARRAY[]::INTEGER[], + ADD COLUMN IF NOT EXISTS "tags" TEXT[] NOT NULL DEFAULT ARRAY[]::TEXT[], + ADD COLUMN IF NOT EXISTS "specs" JSONB, + ADD COLUMN IF NOT EXISTS "mask" JSONB, + ADD COLUMN IF NOT EXISTS "story" TEXT, + ADD COLUMN IF NOT EXISTS "scene" TEXT, + ADD COLUMN IF NOT EXISTS "iconImg" TEXT, + ADD COLUMN IF NOT EXISTS "sort" INTEGER NOT NULL DEFAULT 0; + +ALTER TABLE "Order" + ADD COLUMN IF NOT EXISTS "requestId" TEXT, + ADD COLUMN IF NOT EXISTS "paidAt" TIMESTAMP(3); + +CREATE UNIQUE INDEX IF NOT EXISTS "Order_userId_requestId_key" ON "Order"("userId", "requestId"); diff --git a/prisma/migrations/20260913113000_payment_timeout/migration.sql b/prisma/migrations/20260913113000_payment_timeout/migration.sql new file mode 100644 index 0000000..f4c0b5e --- /dev/null +++ b/prisma/migrations/20260913113000_payment_timeout/migration.sql @@ -0,0 +1,11 @@ +-- 待付款订单统一在服务端以创建后 30 分钟为截止时间,避免只由客户端倒计时控制。 +ALTER TYPE "OrderStatus" ADD VALUE IF NOT EXISTS 'PAYMENT_EXPIRED'; + +ALTER TABLE "Order" ADD COLUMN IF NOT EXISTS "paymentExpiresAt" TIMESTAMP(3); + +-- 为已有待付款订单补齐截止时间;历史订单会按创建时间计算并在下一次读取时自动过期。 +UPDATE "Order" +SET "paymentExpiresAt" = "createdAt" + INTERVAL '30 minutes' +WHERE "paymentExpiresAt" IS NULL AND "status" = 'PENDING'; + +CREATE INDEX IF NOT EXISTS "Order_paymentExpiresAt_idx" ON "Order"("paymentExpiresAt"); diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 94ce1a4..cf7ec5c 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -56,8 +56,19 @@ model Product { name String categoryId String? price Decimal @db.Decimal(10, 2) + originalPrice Decimal? @db.Decimal(10, 2) + leadTime String @default("7-10 个工作日") + subtitle String? + tone Int[] + tags String[] + specs Json? + mask Json? + story String? + scene String? + iconImg String? images String[] description String? + sort Int @default(0) status ProductStatus @default(DRAFT) category Category? @relation(fields: [categoryId], references: [id]) orderItems OrderItem[] @@ -127,6 +138,10 @@ model Order { status OrderStatus @default(PENDING) totalAmount Decimal @db.Decimal(10, 2) addressSnapshot Json + requestId String? + paidAt DateTime? + // 待付款订单的固定截止时刻;创建后 30 分钟不可再支付。 + paymentExpiresAt DateTime? // 关联的设计清单(R4 下单后 WCD 派单据此读取 designData) designListId String? user User @relation(fields: [userId], references: [id]) @@ -139,7 +154,9 @@ model Order { @@index([userId]) @@index([status]) + @@index([paymentExpiresAt]) @@index([designListId]) + @@unique([userId, requestId]) } enum OrderStatus { @@ -149,6 +166,7 @@ enum OrderStatus { SHIPPED COMPLETED CANCELLED + PAYMENT_EXPIRED } model OrderItem { diff --git a/prisma/seed.js b/prisma/seed.js new file mode 100644 index 0000000..e96e4d1 --- /dev/null +++ b/prisma/seed.js @@ -0,0 +1,20 @@ +const { PrismaClient } = require('@prisma/client'); + +const prisma = new PrismaClient(); +const products = [ + { id: 'notebook-small', name: '微雕笔记本(小)', price: 12, originalPrice: 18, leadTime: '3-5个工作日', iconImg: '/icon/书本.png', images: ['/img/book_small/The1.jpg'], mask: { shape: 'rect', width: 300, height: 420, borderRadius: 8 }, tags: ['伴手礼', '学生', '手账'], sort: 1 }, + { id: 'notebook-large', name: '微雕笔记本(大)', price: 45, originalPrice: 58, leadTime: '3-5个工作日', iconImg: '/icon/书本_大.png', images: ['/img/book_big/The1.jpg', '/img/book_big/The2.jpg'], mask: { shape: 'rect', width: 340, height: 480, borderRadius: 8 }, tags: ['毕业礼', '团建', '企业定制'], sort: 2 }, + { id: 'coaster', name: '铜质杯垫', price: 25, originalPrice: 35, leadTime: '5-7个工作日', iconImg: '/icon/杯子.png', images: ['/img/cup/The1.jpg'], mask: { shape: 'circle', width: 280, height: 280 }, tags: ['乔迁礼', '黄铜', '桌面美学'], sort: 3 }, + { id: 'penbox', name: '竹制笔盒', price: 75, originalPrice: 98, leadTime: '7-10个工作日', iconImg: '/icon/笔盒.png', images: ['/img/penbox/The1.jpg', '/img/penbox/The2.jpg', '/img/penbox/The3.jpg'], mask: { shape: 'rect', width: 320, height: 160, borderRadius: 12 }, tags: ['文房', '书法', '天然材质'], sort: 4 }, + { id: 'booklamp', name: '书本型灯', price: 45, originalPrice: 68, leadTime: '5-7个工作日', iconImg: '/icon/书灯.png', images: ['/img/booklight/The1.jpg', '/img/booklight/The2.jpg'], mask: { shape: 'rect', width: 320, height: 240, borderRadius: 4 }, tags: ['情侣礼', '夜灯', '创意礼物'], sort: 5 }, +]; + +async function main() { + const category = await prisma.category.upsert({ where: { id: 'cat-custom' }, update: { name: '定制商品', sort: 1 }, create: { id: 'cat-custom', name: '定制商品', sort: 1 } }); + for (const product of products) { + await prisma.product.upsert({ where: { id: product.id }, update: { ...product, categoryId: category.id, status: 'ON_SALE', tone: [28, 22, 18] }, create: { ...product, categoryId: category.id, status: 'ON_SALE', tone: [28, 22, 18] } }); + } + console.log(`Seed 完成:${products.length} 个在售商品已写入`); +} + +main().catch((error) => { console.error(error); process.exitCode = 1; }).finally(() => prisma.$disconnect()); diff --git a/prisma/seed.ts b/prisma/seed.ts index 7686458..bcae0d5 100644 --- a/prisma/seed.ts +++ b/prisma/seed.ts @@ -3,36 +3,26 @@ import { PrismaClient } from '@prisma/client'; const prisma = new PrismaClient(); async function main() { - // 分类 - const apparel = await prisma.category.upsert({ - where: { id: 'cat-apparel' }, - update: {}, - create: { id: 'cat-apparel', name: '服饰', sort: 1 }, + const category = await prisma.category.upsert({ + where: { id: 'cat-custom' }, + update: { name: '定制商品', sort: 1 }, + create: { id: 'cat-custom', name: '定制商品', sort: 1 }, }); - - const tshirt = await prisma.category.upsert({ - where: { id: 'cat-tshirt' }, - update: {}, - create: { id: 'cat-tshirt', name: 'T恤', parentId: apparel.id, sort: 1 }, - }); - - // 示例商品 - await prisma.product.upsert({ - where: { id: 'prod-demo-tshirt' }, - update: {}, - create: { - id: 'prod-demo-tshirt', - name: '示例定制 T恤', - categoryId: tshirt.id, - price: 99.0, - images: [], - description: '用于本地开发联调的示例商品,可在此设计清单中上传自定义图案。', - status: 'ON_SALE', - }, - }); - - // eslint-disable-next-line no-console - console.log('Seed 完成:分类 + 示例商品已写入'); + const products = [ + { id: 'notebook-small', name: '微雕笔记本(小)', price: 12, originalPrice: 18, leadTime: '3-5个工作日', iconImg: '/icon/书本.png', images: ['/img/book_small/The1.jpg'], mask: { shape: 'rect', width: 300, height: 420, borderRadius: 8 }, tags: ['伴手礼', '学生', '手账'], sort: 1 }, + { id: 'notebook-large', name: '微雕笔记本(大)', price: 45, originalPrice: 58, leadTime: '3-5个工作日', iconImg: '/icon/书本_大.png', images: ['/img/book_big/The1.jpg', '/img/book_big/The2.jpg'], mask: { shape: 'rect', width: 340, height: 480, borderRadius: 8 }, tags: ['毕业礼', '团建', '企业定制'], sort: 2 }, + { id: 'coaster', name: '铜质杯垫', price: 25, originalPrice: 35, leadTime: '5-7个工作日', iconImg: '/icon/杯子.png', images: ['/img/cup/The1.jpg'], mask: { shape: 'circle', width: 280, height: 280 }, tags: ['乔迁礼', '黄铜', '桌面美学'], sort: 3 }, + { id: 'penbox', name: '竹制笔盒', price: 75, originalPrice: 98, leadTime: '7-10个工作日', iconImg: '/icon/笔盒.png', images: ['/img/penbox/The1.jpg', '/img/penbox/The2.jpg', '/img/penbox/The3.jpg'], mask: { shape: 'rect', width: 320, height: 160, borderRadius: 12 }, tags: ['文房', '书法', '天然材质'], sort: 4 }, + { id: 'booklamp', name: '书本型灯', price: 45, originalPrice: 68, leadTime: '5-7个工作日', iconImg: '/icon/书灯.png', images: ['/img/booklight/The1.jpg', '/img/booklight/The2.jpg'], mask: { shape: 'rect', width: 320, height: 240, borderRadius: 4 }, tags: ['情侣礼', '夜灯', '创意礼物'], sort: 5 }, + ]; + for (const product of products) { + await prisma.product.upsert({ + where: { id: product.id }, + update: { ...product, categoryId: category.id, status: 'ON_SALE', tone: [28, 22, 18] }, + create: { ...product, categoryId: category.id, status: 'ON_SALE', tone: [28, 22, 18] }, + }); + } + console.log(`Seed 完成:${products.length} 个在售商品已写入`); } main() diff --git a/src/auth/auth.controller.ts b/src/auth/auth.controller.ts index d771cab..868a966 100644 --- a/src/auth/auth.controller.ts +++ b/src/auth/auth.controller.ts @@ -14,10 +14,19 @@ export class AuthController { @ApiOperation({ summary: '登录:wx.login code 换 openid,自动注册/续登并签发 token' }) @ApiOkResponse({ schema: { - example: { code: 0, message: 'ok', data: { accessToken: '...' } }, + example: { + code: 0, + message: 'ok', + data: { accessToken: '...', isNewUser: true, nickname: null, avatar: null }, + }, }, }) - async login(@Body() dto: LoginDto): Promise<{ accessToken: string }> { + async login(@Body() dto: LoginDto): Promise<{ + accessToken: string; + isNewUser: boolean; + nickname: string | null; + avatar: string | null; + }> { return this.authService.login(dto); } diff --git a/src/auth/auth.service.ts b/src/auth/auth.service.ts index 616a6dc..552e774 100644 --- a/src/auth/auth.service.ts +++ b/src/auth/auth.service.ts @@ -34,10 +34,16 @@ export class AuthService { * 说明:个人主体小程序无 getPhoneNumber 权限,故不强制手机号注册。 * 手机号采集留作未来换企业主体后可选补充(见 register())。 */ - async login(dto: LoginDto): Promise<{ accessToken: string }> { + async login(dto: LoginDto): Promise<{ + accessToken: string; + isNewUser: boolean; + nickname: string | null; + avatar: string | null; + }> { const session = await this.wechat.code2Session(dto.code); - // upsert:openid 在库则续登,不在库则直接建用户(无需手机号) + // 先查一次以判断是否为首次登录;随后仍以 upsert 防止并发登录重复建用户。 + const existing = await this.users.findByOpenid(session.openid); const user = await this.users.findOrCreateByOpenid( session.openid, session.unionid, @@ -52,7 +58,12 @@ export class AuthService { ); const accessToken = await this.signToken(user.id, user.openid); - return { accessToken }; + return { + accessToken, + isNewUser: !existing, + nickname: user.nickname, + avatar: user.avatar, + }; } /** diff --git a/src/design-list/design-list.service.ts b/src/design-list/design-list.service.ts index b214de5..ff09dcd 100644 --- a/src/design-list/design-list.service.ts +++ b/src/design-list/design-list.service.ts @@ -33,15 +33,20 @@ export class DesignListService { constructor(private readonly prisma: PrismaService) {} async listByUser(userId: string) { - return this.prisma.designList.findMany({ + const rows = await this.prisma.designList.findMany({ where: { userId }, orderBy: { createdAt: 'desc' }, + include: { orders: { select: { id: true }, orderBy: { createdAt: 'desc' }, take: 1 } }, }); + return rows.map(({ orders, ...row }) => ({ ...row, orderId: orders[0]?.id ?? null })); } async findOne(userId: string, id: string) { - const list = await this.getOwnedList(userId, id); - return list; + await this.getOwnedList(userId, id); + const linked = await this.prisma.designList.findUnique({ where: { id }, include: { orders: { select: { id: true }, orderBy: { createdAt: 'desc' }, take: 1 } } }); + if (!linked) throw new NotFoundException('设计清单不存在'); + const { orders, ...row } = linked; + return { ...row, orderId: orders[0]?.id ?? null }; } async create(userId: string, dto: CreateDesignListDto) { diff --git a/src/orders/dto/create-order.dto.ts b/src/orders/dto/create-order.dto.ts index 02f9a0d..b740565 100644 --- a/src/orders/dto/create-order.dto.ts +++ b/src/orders/dto/create-order.dto.ts @@ -1,31 +1,26 @@ import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; -import { IsArray, IsNotEmpty, IsObject, IsOptional, IsString, ValidateNested } from 'class-validator'; +import { IsArray, IsInt, IsNotEmpty, IsOptional, IsString, Max, Min, ValidateNested } from 'class-validator'; import { Type } from 'class-transformer'; export class OrderItemDto { - @ApiPropertyOptional({ description: '商品 id(定制类可空)' }) - @IsOptional() - @IsString() - productId?: string; - - @ApiProperty() + @ApiProperty({ description: '商品 id' }) @IsString() @IsNotEmpty() - name!: string; - - @ApiProperty({ description: '单价(元)' }) - @Type(() => Number) - price!: number; + productId!: string; @ApiProperty({ description: '数量' }) @Type(() => Number) + @IsInt() + @Min(1) + @Max(999) quantity!: number; } export class CreateOrderDto { - @ApiProperty({ description: '收货地址快照(JSON)' }) - @IsObject() - addressSnapshot!: Record; + @ApiProperty({ description: '本人收货地址 id' }) + @IsString() + @IsNotEmpty() + addressId!: string; @ApiProperty({ type: [OrderItemDto] }) @IsArray() @@ -37,4 +32,10 @@ export class CreateOrderDto { @IsOptional() @IsString() designListId?: string; + + @ApiPropertyOptional({ description: '客户端幂等键' }) + @IsOptional() + @IsString() + @IsNotEmpty() + requestId?: string; } diff --git a/src/orders/dto/order-query.dto.ts b/src/orders/dto/order-query.dto.ts new file mode 100644 index 0000000..3a6ff09 --- /dev/null +++ b/src/orders/dto/order-query.dto.ts @@ -0,0 +1,11 @@ +import { ApiPropertyOptional } from '@nestjs/swagger'; +import { IsEnum, IsOptional } from 'class-validator'; +import { OrderStatus } from '@prisma/client'; +import { PaginationDto } from '../../common/dto/pagination.dto'; + +export class OrderQueryDto extends PaginationDto { + @ApiPropertyOptional({ enum: OrderStatus }) + @IsOptional() + @IsEnum(OrderStatus) + status?: OrderStatus; +} diff --git a/src/orders/orders.controller.ts b/src/orders/orders.controller.ts index 7f9466a..588000d 100644 --- a/src/orders/orders.controller.ts +++ b/src/orders/orders.controller.ts @@ -1,8 +1,9 @@ -import { Body, Controller, Get, Param, Post } from '@nestjs/common'; +import { Body, Controller, Get, Param, Patch, Post, Query } from '@nestjs/common'; import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger'; import { CurrentUser, JwtPayload } from '../common/decorators/current-user.decorator'; import { OrdersService } from './orders.service'; import { CreateOrderDto } from './dto/create-order.dto'; +import { OrderQueryDto } from './dto/order-query.dto'; @ApiTags('订单') @ApiBearerAuth() @@ -12,14 +13,14 @@ export class OrdersController { @Get() @ApiOperation({ summary: '我的订单列表' }) - list(@CurrentUser() user: JwtPayload) { - return this.ordersService.listByUser(user.sub); + list(@CurrentUser() user: JwtPayload, @Query() query: OrderQueryDto) { + return this.ordersService.listByUser(user.sub, query); } @Get(':id') @ApiOperation({ summary: '订单详情' }) - findOne(@Param('id') id: string) { - return this.ordersService.findOne(id); + findOne(@CurrentUser() user: JwtPayload, @Param('id') id: string) { + return this.ordersService.findOne(user.sub, id); } @Post() @@ -27,4 +28,16 @@ export class OrdersController { create(@CurrentUser() user: JwtPayload, @Body() dto: CreateOrderDto) { return this.ordersService.create(user.sub, dto); } + + @Patch(':id/confirm') + @ApiOperation({ summary: '确认收货(SHIPPED -> COMPLETED)' }) + confirm(@CurrentUser() user: JwtPayload, @Param('id') id: string) { + return this.ordersService.confirm(user.sub, id); + } + + @Post(':id/cancel') + @ApiOperation({ summary: '取消订单(仅 PENDING)' }) + cancel(@CurrentUser() user: JwtPayload, @Param('id') id: string) { + return this.ordersService.cancel(user.sub, id); + } } diff --git a/src/orders/orders.service.ts b/src/orders/orders.service.ts index cd256a5..1482ac3 100644 --- a/src/orders/orders.service.ts +++ b/src/orders/orders.service.ts @@ -1,7 +1,10 @@ -import { Injectable } from '@nestjs/common'; -import { Prisma } from '@prisma/client'; +import { ConflictException, ForbiddenException, Injectable, NotFoundException, BadRequestException } from '@nestjs/common'; +import { OrderStatus, Prisma } from '@prisma/client'; import { PrismaService } from '../prisma/prisma.service'; import { CreateOrderDto } from './dto/create-order.dto'; +import { OrderQueryDto } from './dto/order-query.dto'; + +const PAYMENT_TIMEOUT_MS = 30 * 60 * 1000; // 订单号生成:日期 + 随机后缀(无 Math.random 依赖要求仅限 workflow 脚本,普通运行时可用) function generateOrderNo(): string { @@ -18,43 +21,122 @@ function generateOrderNo(): string { export class OrdersService { constructor(private readonly prisma: PrismaService) {} - async listByUser(userId: string) { - return this.prisma.order.findMany({ - where: { userId }, - include: { items: true, payment: true }, - orderBy: { createdAt: 'desc' }, - }); + private serialize(order: any) { + return { + ...order, + totalAmount: Number(order.totalAmount), + paidAt: order.paidAt?.toISOString?.() ?? null, + paymentExpiresAt: order.paymentExpiresAt?.toISOString?.() ?? null, + createdAt: order.createdAt.toISOString(), + updatedAt: order.updatedAt.toISOString(), + items: order.items?.map((item: any) => ({ + ...item, + price: Number(item.price), + })), + }; } - async findOne(id: string) { - // TODO: 权限校验(仅本人) - return this.prisma.order.findUnique({ - where: { id }, - include: { items: true, payment: true }, - }); + async listByUser(userId: string, query: OrderQueryDto) { + await this.expirePendingOrders({ userId }); + const where = { userId, ...(query.status ? { status: query.status } : {}) }; + const [rows, total] = await this.prisma.$transaction([ + this.prisma.order.findMany({ where, include: { items: true, payment: true }, orderBy: { createdAt: 'desc' }, skip: (query.page - 1) * query.pageSize, take: query.pageSize }), + this.prisma.order.count({ where }), + ]); + return { list: rows.map((row) => this.serialize(row)), total, page: query.page, pageSize: query.pageSize }; + } + + async findOne(userId: string, id: string) { + await this.expirePendingOrders({ id, userId }); + const order = await this.prisma.order.findUnique({ where: { id }, include: { items: true, payment: true } }); + if (!order) throw new NotFoundException('订单不存在'); + if (order.userId !== userId) throw new ForbiddenException('无权访问该订单'); + return this.serialize(order); } async create(userId: string, dto: CreateOrderDto) { - // TODO: 商品库存/价格校验、事务原子性、金额防篡改(服务端重算 totalAmount) - const totalAmount = dto.items.reduce((sum, it) => sum + it.price * it.quantity, 0); + if (dto.requestId) { + const existing = await this.prisma.order.findFirst({ where: { userId, requestId: dto.requestId }, include: { items: true, payment: true } }); + if (existing) return this.serialize(existing); + } + const address = await this.prisma.address.findUnique({ where: { id: dto.addressId } }); + if (!address) throw new NotFoundException('地址不存在'); + if (address.userId !== userId) throw new ForbiddenException('无权使用该地址'); + const design = dto.designListId ? await this.prisma.designList.findUnique({ where: { id: dto.designListId } }) : null; + if (dto.designListId && !design) throw new NotFoundException('设计清单不存在'); + if (design && design.userId !== userId) throw new ForbiddenException('无权使用该设计清单'); + const products = await this.prisma.product.findMany({ where: { id: { in: dto.items.map((item) => item.productId) }, status: 'ON_SALE' } }); + if (products.length !== new Set(dto.items.map((item) => item.productId)).size) throw new BadRequestException('存在无效或已下架商品'); + const byId = new Map(products.map((product) => [product.id, product])); + const total = dto.items.reduce((sum, item) => sum.plus(new Prisma.Decimal(byId.get(item.productId)!.price).mul(item.quantity)), new Prisma.Decimal(0)); + const addressSnapshot = { id: address.id, name: address.name, phone: address.phone, province: address.province, city: address.city, district: address.district, detail: address.detail }; + const paymentExpiresAt = new Date(Date.now() + PAYMENT_TIMEOUT_MS); + try { + const created = await this.prisma.$transaction(async (tx) => { + let orderNo = generateOrderNo(); + for (let attempt = 0; attempt < 3; attempt += 1) { + const collision = await tx.order.findUnique({ where: { orderNo }, select: { id: true } }); + if (!collision) break; + orderNo = generateOrderNo(); + } + return tx.order.create({ + data: { + orderNo, + user: { connect: { id: userId } }, + requestId: dto.requestId, + totalAmount: total, + paymentExpiresAt, + addressSnapshot: addressSnapshot as Prisma.InputJsonValue, + designList: dto.designListId ? { connect: { id: dto.designListId } } : undefined, + items: { create: dto.items.map((item) => ({ productId: item.productId, name: byId.get(item.productId)!.name, price: byId.get(item.productId)!.price, quantity: item.quantity })) }, + payment: { create: { status: 'PENDING' } }, + }, + include: { items: true, payment: true }, + }); + }); + return this.serialize(created); + } catch (error) { + if (error instanceof Prisma.PrismaClientKnownRequestError && error.code === 'P2002') { + const existing = dto.requestId ? await this.prisma.order.findFirst({ where: { userId, requestId: dto.requestId }, include: { items: true, payment: true } }) : null; + if (existing) return this.serialize(existing); + throw new ConflictException('订单号冲突,请重试'); + } + throw error; + } + } - const data: Prisma.OrderCreateInput = { - orderNo: generateOrderNo(), - user: { connect: { id: userId } }, - totalAmount, - addressSnapshot: dto.addressSnapshot as Prisma.InputJsonValue, - // 关联设计清单(R4 下单后 WCD 派单读取 designData;R3 契约 CreateOrderDto 已含该字段) - designList: dto.designListId ? { connect: { id: dto.designListId } } : undefined, - items: { - create: dto.items.map((it) => ({ - productId: it.productId, - name: it.name, - price: it.price, - quantity: it.quantity, - })), + async cancel(userId: string, id: string) { + const order = await this.getOwned(userId, id); + if (order.status !== OrderStatus.PENDING) throw new ConflictException('仅待付款订单可取消'); + return this.serialize(await this.prisma.order.update({ where: { id }, data: { status: OrderStatus.CANCELLED }, include: { items: true, payment: true } })); + } + + async confirm(userId: string, id: string) { + const order = await this.getOwned(userId, id); + if (order.status !== OrderStatus.SHIPPED) throw new ConflictException('仅待收货订单可确认收货'); + return this.serialize(await this.prisma.order.update({ where: { id }, data: { status: OrderStatus.COMPLETED }, include: { items: true, payment: true } })); + } + + private async getOwned(userId: string, id: string) { + await this.expirePendingOrders({ id, userId }); + const order = await this.prisma.order.findUnique({ where: { id }, include: { items: true, payment: true } }); + if (!order) throw new NotFoundException('订单不存在'); + if (order.userId !== userId) throw new ForbiddenException('无权操作该订单'); + return order; + } + + /** + * 过期状态由服务端收口,而不是只依赖小程序倒计时。这样用户切后台、换设备或 + * 直接调用支付接口时,订单仍无法绕过 30 分钟限制。 + */ + private async expirePendingOrders(where: Prisma.OrderWhereInput) { + await this.prisma.order.updateMany({ + where: { + ...where, + status: OrderStatus.PENDING, + paymentExpiresAt: { lte: new Date() }, }, - }; - - return this.prisma.order.create({ data, include: { items: true } }); + data: { status: OrderStatus.PAYMENT_EXPIRED }, + }); } } diff --git a/src/payments/payments.controller.ts b/src/payments/payments.controller.ts index cf9b5e6..2a6c9d4 100644 --- a/src/payments/payments.controller.ts +++ b/src/payments/payments.controller.ts @@ -2,6 +2,7 @@ import { Body, Controller, Param, Post, Req } from '@nestjs/common'; import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger'; import { Public } from '../common/decorators/public.decorator'; import { PaymentsService } from './payments.service'; +import { CurrentUser, JwtPayload } from '../common/decorators/current-user.decorator'; @ApiTags('支付') @ApiBearerAuth() @@ -11,8 +12,8 @@ export class PaymentsController { @Post(':orderId/pay') @ApiOperation({ summary: '对指定订单发起支付(占位)' }) - pay(@Param('orderId') orderId: string) { - return this.paymentsService.createPayment(orderId); + pay(@CurrentUser() user: JwtPayload, @Param('orderId') orderId: string) { + return this.paymentsService.createPayment(user.sub, orderId); } @Public() diff --git a/src/payments/payments.service.ts b/src/payments/payments.service.ts index aa175e8..89707b9 100644 --- a/src/payments/payments.service.ts +++ b/src/payments/payments.service.ts @@ -1,4 +1,6 @@ -import { Injectable } from '@nestjs/common'; +import { ConflictException, ForbiddenException, Injectable, NotFoundException } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { OrderStatus } from '@prisma/client'; import { PrismaService } from '../prisma/prisma.service'; import { WechatService } from '../wechat/wechat.service'; @@ -7,15 +9,32 @@ export class PaymentsService { constructor( private readonly prisma: PrismaService, private readonly wechat: WechatService, + private readonly config: ConfigService, ) {} /** 发起支付:创建支付记录并调微信统一下单(本轮占位) */ - async createPayment(orderId: string) { - // TODO: 查订单、校验状态/金额、创建 Payment 记录、调 wechat.createUnifiedOrder - void this.wechat; // 占位引用,避免未使用告警 - return this.prisma.payment.create({ - data: { order: { connect: { id: orderId } } }, - }); + async createPayment(userId: string, orderId: string) { + const order = await this.prisma.order.findUnique({ where: { id: orderId }, include: { payment: true } }); + if (!order) throw new NotFoundException('订单不存在'); + if (order.userId !== userId) throw new ForbiddenException('无权支付该订单'); + if (order.status === OrderStatus.PENDING && order.paymentExpiresAt && order.paymentExpiresAt <= new Date()) { + await this.prisma.order.updateMany({ + where: { id: order.id, userId, status: OrderStatus.PENDING }, + data: { status: OrderStatus.PAYMENT_EXPIRED }, + }); + throw new ConflictException('订单超时未支付'); + } + if (order.status === OrderStatus.PAYMENT_EXPIRED) throw new ConflictException('订单超时未支付'); + if (order.status !== OrderStatus.PENDING) throw new ConflictException('当前订单不可支付'); + const configured = Boolean( + this.config.get('wx.mchId') && + this.config.get('wx.mchApiV3Key') && + this.config.get('wx.mchSerialNo') && + this.config.get('wx.mchPrivateKeyPath'), + ); + if (!configured) return { configured: false, message: '支付未配置' }; + void this.wechat; + return { configured: true, message: '支付服务已配置但统一下单尚未接入' }; } /** 微信支付回调入口(本轮占位) */ diff --git a/src/products/dto/create-product.dto.ts b/src/products/dto/create-product.dto.ts index b7030ca..c446e45 100644 --- a/src/products/dto/create-product.dto.ts +++ b/src/products/dto/create-product.dto.ts @@ -1,6 +1,6 @@ import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; import { Type } from 'class-transformer'; -import { IsArray, IsEnum, IsNumber, IsOptional, IsString, Min } from 'class-validator'; +import { IsArray, IsEnum, IsNumber, IsObject, IsOptional, IsString, Min } from 'class-validator'; import { ProductStatus } from '@prisma/client'; export class CreateProductDto { @@ -19,6 +19,60 @@ export class CreateProductDto { @Min(0) price!: number; + @ApiPropertyOptional({ description: '划线原价(元)' }) + @IsOptional() + @Type(() => Number) + @IsNumber() + @Min(0) + originalPrice?: number; + + @ApiPropertyOptional({ default: '7-10 个工作日' }) + @IsOptional() + @IsString() + leadTime?: string; + + @ApiPropertyOptional() + @IsOptional() + @IsString() + subtitle?: string; + + @ApiPropertyOptional({ type: [Number] }) + @IsOptional() + @IsArray() + @IsNumber({}, { each: true }) + tone?: number[]; + + @ApiPropertyOptional({ type: [String] }) + @IsOptional() + @IsArray() + @IsString({ each: true }) + tags?: string[]; + + @ApiPropertyOptional({ type: Object }) + @IsOptional() + @IsObject() + specs?: Record; + + @ApiPropertyOptional({ type: Object }) + @IsOptional() + @IsObject() + mask?: Record; + + @ApiPropertyOptional() + @IsOptional() + @IsString() + story?: string; + + @ApiPropertyOptional() + @IsOptional() + @IsString() + scene?: string; + + @ApiPropertyOptional() + @IsOptional() + @IsString() + iconImg?: string; + @ApiPropertyOptional({ description: '图片 URL 列表', type: [String] }) @IsOptional() @IsArray() diff --git a/src/products/dto/product-query.dto.ts b/src/products/dto/product-query.dto.ts new file mode 100644 index 0000000..12ce547 --- /dev/null +++ b/src/products/dto/product-query.dto.ts @@ -0,0 +1,15 @@ +import { ApiPropertyOptional } from '@nestjs/swagger'; +import { IsOptional, IsString } from 'class-validator'; +import { PaginationDto } from '../../common/dto/pagination.dto'; + +export class ProductQueryDto extends PaginationDto { + @ApiPropertyOptional() + @IsOptional() + @IsString() + categoryId?: string; + + @ApiPropertyOptional() + @IsOptional() + @IsString() + keyword?: string; +} diff --git a/src/products/products.controller.ts b/src/products/products.controller.ts index 935eb12..45cc8e7 100644 --- a/src/products/products.controller.ts +++ b/src/products/products.controller.ts @@ -1,8 +1,9 @@ -import { Body, Controller, Get, Param, Post } from '@nestjs/common'; +import { Body, Controller, Get, Param, Post, Query } from '@nestjs/common'; import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger'; import { Public } from '../common/decorators/public.decorator'; import { ProductsService } from './products.service'; import { CreateProductDto } from './dto/create-product.dto'; +import { ProductQueryDto } from './dto/product-query.dto'; @ApiTags('商品') @ApiBearerAuth() @@ -13,8 +14,8 @@ export class ProductsController { @Public() @Get() @ApiOperation({ summary: '在售商品列表' }) - list() { - return this.productsService.list(); + list(@Query() query: ProductQueryDto) { + return this.productsService.list(query); } @Public() diff --git a/src/products/products.service.ts b/src/products/products.service.ts index c476473..dcb5727 100644 --- a/src/products/products.service.ts +++ b/src/products/products.service.ts @@ -1,27 +1,80 @@ -import { Injectable } from '@nestjs/common'; -import { ProductStatus } from '@prisma/client'; +import { Injectable, NotFoundException } from '@nestjs/common'; +import { Product, ProductStatus } from '@prisma/client'; import { PrismaService } from '../prisma/prisma.service'; import { CreateProductDto } from './dto/create-product.dto'; +import { ProductQueryDto } from './dto/product-query.dto'; + +type ProductResponse = Omit & { + price: number; + originalPrice?: number; +}; + +function serialize(product: Product): ProductResponse { + return { + ...product, + price: Number(product.price), + originalPrice: product.originalPrice == null ? undefined : Number(product.originalPrice), + }; +} @Injectable() export class ProductsService { constructor(private readonly prisma: PrismaService) {} - async list() { - // TODO: 分页、按分类/状态筛选、价格区间 - return this.prisma.product.findMany({ - where: { status: ProductStatus.ON_SALE }, - orderBy: { createdAt: 'desc' }, - }); + async list(query: ProductQueryDto) { + const where = { + status: ProductStatus.ON_SALE, + ...(query.categoryId ? { categoryId: query.categoryId } : {}), + ...(query.keyword + ? { + OR: [ + { name: { contains: query.keyword, mode: 'insensitive' as const } }, + { description: { contains: query.keyword, mode: 'insensitive' as const } }, + ], + } + : {}), + }; + const [rows, total] = await this.prisma.$transaction([ + this.prisma.product.findMany({ + where, + orderBy: [{ sort: 'asc' }, { createdAt: 'desc' }], + skip: (query.page - 1) * query.pageSize, + take: query.pageSize, + }), + this.prisma.product.count({ where }), + ]); + return { list: rows.map(serialize), total, page: query.page, pageSize: query.pageSize }; } async findOne(id: string) { - // TODO: 404 处理、浏览量统计 - return this.prisma.product.findUnique({ where: { id } }); + const product = await this.prisma.product.findFirst({ + where: { id, status: ProductStatus.ON_SALE }, + }); + if (!product) throw new NotFoundException('商品不存在'); + return serialize(product); } async create(dto: CreateProductDto) { - // TODO: 校验 categoryId 存在、图片归属 - return this.prisma.product.create({ data: dto }); + const product = await this.prisma.product.create({ + data: { + name: dto.name, + categoryId: dto.categoryId, + price: dto.price, + originalPrice: dto.originalPrice, + leadTime: dto.leadTime, + subtitle: dto.subtitle, + tone: dto.tone ?? [], + tags: dto.tags ?? [], + specs: dto.specs as any, + mask: dto.mask as any, + story: dto.story, + scene: dto.scene, + iconImg: dto.iconImg, + images: dto.images ?? [], + description: dto.description, + status: dto.status, + }, + }); + return serialize(product); } }