feat(r3): complete order and payment flow

This commit is contained in:
2026-09-13 13:56:59 +08:00
parent 63c0013076
commit 16e625aefb
20 changed files with 454 additions and 126 deletions
+5 -10
View File
@@ -35,15 +35,10 @@
```json ```json
{ {
"accessToken": "jwt", "accessToken": "jwt"
"isNewUser": true,
"nickname": null,
"avatar": null
} }
``` ```
`isNewUser=true` 时前端引导补全昵称/头像。
### POST /api/auth/register ### POST /api/auth/register
公开接口。企业主体可选:换手机号注册,返回 `{ accessToken }` 公开接口。企业主体可选:换手机号注册,返回 `{ accessToken }`
@@ -56,7 +51,7 @@
更新当前用户资料。请求体:`{ nickname?: string, avatar?: string }` 更新当前用户资料。请求体:`{ nickname?: string, avatar?: string }`
## 3. 商品与分类(R1实现) ## 3. 商品与分类(R1实现)
### GET /api/categories ### GET /api/categories
@@ -104,7 +99,7 @@
} }
``` ```
## 4. 收货地址(R2实现完整 CRUD ## 4. 收货地址(R2实现 CRUD
### GET /api/addresses ### GET /api/addresses
@@ -143,7 +138,7 @@
注意:前端 `AddressItem.region: string[]` 与后端 `province/city/district` 的转换 注意:前端 `AddressItem.region: string[]` 与后端 `province/city/district` 的转换
只允许出现在前端 `src/utils/api/address.ts` 只允许出现在前端 `src/utils/api/address.ts`
## 5. 设计清单(R2实现完整 CRUD ## 5. 设计清单(R2实现 CRUD
### GET /api/design-list ### GET /api/design-list
@@ -212,7 +207,7 @@
`ordered``orderId != null` 派生,前端不提交该状态。 `ordered``orderId != null` 派生,前端不提交该状态。
## 6. 订单(R3实现) ## 6. 订单(R3实现)
### POST /api/orders ### POST /api/orders
+1 -1
View File
@@ -60,7 +60,7 @@
"typescript": "^5.6.0" "typescript": "^5.6.0"
}, },
"prisma": { "prisma": {
"seed": "ts-node prisma/seed.ts" "seed": "node prisma/seed.js"
}, },
"engines": { "engines": {
"node": ">=22" "node": ">=22"
@@ -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");
@@ -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");
+18
View File
@@ -56,8 +56,19 @@ model Product {
name String name String
categoryId String? categoryId String?
price Decimal @db.Decimal(10, 2) 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[] images String[]
description String? description String?
sort Int @default(0)
status ProductStatus @default(DRAFT) status ProductStatus @default(DRAFT)
category Category? @relation(fields: [categoryId], references: [id]) category Category? @relation(fields: [categoryId], references: [id])
orderItems OrderItem[] orderItems OrderItem[]
@@ -127,6 +138,10 @@ model Order {
status OrderStatus @default(PENDING) status OrderStatus @default(PENDING)
totalAmount Decimal @db.Decimal(10, 2) totalAmount Decimal @db.Decimal(10, 2)
addressSnapshot Json addressSnapshot Json
requestId String?
paidAt DateTime?
// 待付款订单的固定截止时刻;创建后 30 分钟不可再支付。
paymentExpiresAt DateTime?
// 关联的设计清单(R4 下单后 WCD 派单据此读取 designData // 关联的设计清单(R4 下单后 WCD 派单据此读取 designData
designListId String? designListId String?
user User @relation(fields: [userId], references: [id]) user User @relation(fields: [userId], references: [id])
@@ -139,7 +154,9 @@ model Order {
@@index([userId]) @@index([userId])
@@index([status]) @@index([status])
@@index([paymentExpiresAt])
@@index([designListId]) @@index([designListId])
@@unique([userId, requestId])
} }
enum OrderStatus { enum OrderStatus {
@@ -149,6 +166,7 @@ enum OrderStatus {
SHIPPED SHIPPED
COMPLETED COMPLETED
CANCELLED CANCELLED
PAYMENT_EXPIRED
} }
model OrderItem { model OrderItem {
+20
View File
@@ -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());
+17 -27
View File
@@ -3,36 +3,26 @@ import { PrismaClient } from '@prisma/client';
const prisma = new PrismaClient(); const prisma = new PrismaClient();
async function main() { async function main() {
// 分类 const category = await prisma.category.upsert({
const apparel = await prisma.category.upsert({ where: { id: 'cat-custom' },
where: { id: 'cat-apparel' }, update: { name: '定制商品', sort: 1 },
update: {}, create: { id: 'cat-custom', name: '定制商品', sort: 1 },
create: { id: 'cat-apparel', name: '服饰', sort: 1 },
}); });
const products = [
const tshirt = await prisma.category.upsert({ { 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 },
where: { id: 'cat-tshirt' }, { 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 },
update: {}, { 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 },
create: { id: 'cat-tshirt', name: 'T恤', parentId: apparel.id, sort: 1 }, { 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({ await prisma.product.upsert({
where: { id: 'prod-demo-tshirt' }, where: { id: product.id },
update: {}, update: { ...product, categoryId: category.id, status: 'ON_SALE', tone: [28, 22, 18] },
create: { create: { ...product, categoryId: category.id, status: 'ON_SALE', tone: [28, 22, 18] },
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 完成:${products.length} 个在售商品已写入`);
console.log('Seed 完成:分类 + 示例商品已写入');
} }
main() main()
+11 -2
View File
@@ -14,10 +14,19 @@ export class AuthController {
@ApiOperation({ summary: '登录:wx.login code 换 openid,自动注册/续登并签发 token' }) @ApiOperation({ summary: '登录:wx.login code 换 openid,自动注册/续登并签发 token' })
@ApiOkResponse({ @ApiOkResponse({
schema: { 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); return this.authService.login(dto);
} }
+14 -3
View File
@@ -34,10 +34,16 @@ export class AuthService {
* 说明:个人主体小程序无 getPhoneNumber 权限,故不强制手机号注册。 * 说明:个人主体小程序无 getPhoneNumber 权限,故不强制手机号注册。
* 手机号采集留作未来换企业主体后可选补充(见 register())。 * 手机号采集留作未来换企业主体后可选补充(见 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); 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( const user = await this.users.findOrCreateByOpenid(
session.openid, session.openid,
session.unionid, session.unionid,
@@ -52,7 +58,12 @@ export class AuthService {
); );
const accessToken = await this.signToken(user.id, user.openid); const accessToken = await this.signToken(user.id, user.openid);
return { accessToken }; return {
accessToken,
isNewUser: !existing,
nickname: user.nickname,
avatar: user.avatar,
};
} }
/** /**
+8 -3
View File
@@ -33,15 +33,20 @@ export class DesignListService {
constructor(private readonly prisma: PrismaService) {} constructor(private readonly prisma: PrismaService) {}
async listByUser(userId: string) { async listByUser(userId: string) {
return this.prisma.designList.findMany({ const rows = await this.prisma.designList.findMany({
where: { userId }, where: { userId },
orderBy: { createdAt: 'desc' }, 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) { async findOne(userId: string, id: string) {
const list = await this.getOwnedList(userId, id); await this.getOwnedList(userId, id);
return list; 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) { async create(userId: string, dto: CreateDesignListDto) {
+16 -15
View File
@@ -1,31 +1,26 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; 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'; import { Type } from 'class-transformer';
export class OrderItemDto { export class OrderItemDto {
@ApiPropertyOptional({ description: '商品 id(定制类可空)' }) @ApiProperty({ description: '商品 id' })
@IsOptional()
@IsString()
productId?: string;
@ApiProperty()
@IsString() @IsString()
@IsNotEmpty() @IsNotEmpty()
name!: string; productId!: string;
@ApiProperty({ description: '单价(元)' })
@Type(() => Number)
price!: number;
@ApiProperty({ description: '数量' }) @ApiProperty({ description: '数量' })
@Type(() => Number) @Type(() => Number)
@IsInt()
@Min(1)
@Max(999)
quantity!: number; quantity!: number;
} }
export class CreateOrderDto { export class CreateOrderDto {
@ApiProperty({ description: '收货地址快照(JSON' }) @ApiProperty({ description: '本人收货地址 id' })
@IsObject() @IsString()
addressSnapshot!: Record<string, unknown>; @IsNotEmpty()
addressId!: string;
@ApiProperty({ type: [OrderItemDto] }) @ApiProperty({ type: [OrderItemDto] })
@IsArray() @IsArray()
@@ -37,4 +32,10 @@ export class CreateOrderDto {
@IsOptional() @IsOptional()
@IsString() @IsString()
designListId?: string; designListId?: string;
@ApiPropertyOptional({ description: '客户端幂等键' })
@IsOptional()
@IsString()
@IsNotEmpty()
requestId?: string;
} }
+11
View File
@@ -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;
}
+18 -5
View File
@@ -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 { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
import { CurrentUser, JwtPayload } from '../common/decorators/current-user.decorator'; import { CurrentUser, JwtPayload } from '../common/decorators/current-user.decorator';
import { OrdersService } from './orders.service'; import { OrdersService } from './orders.service';
import { CreateOrderDto } from './dto/create-order.dto'; import { CreateOrderDto } from './dto/create-order.dto';
import { OrderQueryDto } from './dto/order-query.dto';
@ApiTags('订单') @ApiTags('订单')
@ApiBearerAuth() @ApiBearerAuth()
@@ -12,14 +13,14 @@ export class OrdersController {
@Get() @Get()
@ApiOperation({ summary: '我的订单列表' }) @ApiOperation({ summary: '我的订单列表' })
list(@CurrentUser() user: JwtPayload) { list(@CurrentUser() user: JwtPayload, @Query() query: OrderQueryDto) {
return this.ordersService.listByUser(user.sub); return this.ordersService.listByUser(user.sub, query);
} }
@Get(':id') @Get(':id')
@ApiOperation({ summary: '订单详情' }) @ApiOperation({ summary: '订单详情' })
findOne(@Param('id') id: string) { findOne(@CurrentUser() user: JwtPayload, @Param('id') id: string) {
return this.ordersService.findOne(id); return this.ordersService.findOne(user.sub, id);
} }
@Post() @Post()
@@ -27,4 +28,16 @@ export class OrdersController {
create(@CurrentUser() user: JwtPayload, @Body() dto: CreateOrderDto) { create(@CurrentUser() user: JwtPayload, @Body() dto: CreateOrderDto) {
return this.ordersService.create(user.sub, dto); 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);
}
} }
+113 -31
View File
@@ -1,7 +1,10 @@
import { Injectable } from '@nestjs/common'; import { ConflictException, ForbiddenException, Injectable, NotFoundException, BadRequestException } from '@nestjs/common';
import { Prisma } from '@prisma/client'; import { OrderStatus, Prisma } from '@prisma/client';
import { PrismaService } from '../prisma/prisma.service'; import { PrismaService } from '../prisma/prisma.service';
import { CreateOrderDto } from './dto/create-order.dto'; import { CreateOrderDto } from './dto/create-order.dto';
import { OrderQueryDto } from './dto/order-query.dto';
const PAYMENT_TIMEOUT_MS = 30 * 60 * 1000;
// 订单号生成:日期 + 随机后缀(无 Math.random 依赖要求仅限 workflow 脚本,普通运行时可用) // 订单号生成:日期 + 随机后缀(无 Math.random 依赖要求仅限 workflow 脚本,普通运行时可用)
function generateOrderNo(): string { function generateOrderNo(): string {
@@ -18,43 +21,122 @@ function generateOrderNo(): string {
export class OrdersService { export class OrdersService {
constructor(private readonly prisma: PrismaService) {} constructor(private readonly prisma: PrismaService) {}
async listByUser(userId: string) { private serialize(order: any) {
return this.prisma.order.findMany({ return {
where: { userId }, ...order,
include: { items: true, payment: true }, totalAmount: Number(order.totalAmount),
orderBy: { createdAt: 'desc' }, 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) { async listByUser(userId: string, query: OrderQueryDto) {
// TODO: 权限校验(仅本人) await this.expirePendingOrders({ userId });
return this.prisma.order.findUnique({ const where = { userId, ...(query.status ? { status: query.status } : {}) };
where: { id }, const [rows, total] = await this.prisma.$transaction([
include: { items: true, payment: true }, 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) { async create(userId: string, dto: CreateOrderDto) {
// TODO: 商品库存/价格校验、事务原子性、金额防篡改(服务端重算 totalAmount if (dto.requestId) {
const totalAmount = dto.items.reduce((sum, it) => sum + it.price * it.quantity, 0); const existing = await this.prisma.order.findFirst({ where: { userId, requestId: dto.requestId }, include: { items: true, payment: true } });
if (existing) return this.serialize(existing);
const data: Prisma.OrderCreateInput = { }
orderNo: generateOrderNo(), 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 } }, user: { connect: { id: userId } },
totalAmount, requestId: dto.requestId,
addressSnapshot: dto.addressSnapshot as Prisma.InputJsonValue, totalAmount: total,
// 关联设计清单(R4 下单后 WCD 派单读取 designDataR3 契约 CreateOrderDto 已含该字段) paymentExpiresAt,
addressSnapshot: addressSnapshot as Prisma.InputJsonValue,
designList: dto.designListId ? { connect: { id: dto.designListId } } : undefined, designList: dto.designListId ? { connect: { id: dto.designListId } } : undefined,
items: { items: { create: dto.items.map((item) => ({ productId: item.productId, name: byId.get(item.productId)!.name, price: byId.get(item.productId)!.price, quantity: item.quantity })) },
create: dto.items.map((it) => ({ payment: { create: { status: 'PENDING' } },
productId: it.productId,
name: it.name,
price: it.price,
quantity: it.quantity,
})),
}, },
}; 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;
}
}
return this.prisma.order.create({ data, include: { items: true } }); 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() },
},
data: { status: OrderStatus.PAYMENT_EXPIRED },
});
} }
} }
+3 -2
View File
@@ -2,6 +2,7 @@ import { Body, Controller, Param, Post, Req } from '@nestjs/common';
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger'; import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
import { Public } from '../common/decorators/public.decorator'; import { Public } from '../common/decorators/public.decorator';
import { PaymentsService } from './payments.service'; import { PaymentsService } from './payments.service';
import { CurrentUser, JwtPayload } from '../common/decorators/current-user.decorator';
@ApiTags('支付') @ApiTags('支付')
@ApiBearerAuth() @ApiBearerAuth()
@@ -11,8 +12,8 @@ export class PaymentsController {
@Post(':orderId/pay') @Post(':orderId/pay')
@ApiOperation({ summary: '对指定订单发起支付(占位)' }) @ApiOperation({ summary: '对指定订单发起支付(占位)' })
pay(@Param('orderId') orderId: string) { pay(@CurrentUser() user: JwtPayload, @Param('orderId') orderId: string) {
return this.paymentsService.createPayment(orderId); return this.paymentsService.createPayment(user.sub, orderId);
} }
@Public() @Public()
+25 -6
View File
@@ -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 { PrismaService } from '../prisma/prisma.service';
import { WechatService } from '../wechat/wechat.service'; import { WechatService } from '../wechat/wechat.service';
@@ -7,15 +9,32 @@ export class PaymentsService {
constructor( constructor(
private readonly prisma: PrismaService, private readonly prisma: PrismaService,
private readonly wechat: WechatService, private readonly wechat: WechatService,
private readonly config: ConfigService,
) {} ) {}
/** 发起支付:创建支付记录并调微信统一下单(本轮占位) */ /** 发起支付:创建支付记录并调微信统一下单(本轮占位) */
async createPayment(orderId: string) { async createPayment(userId: string, orderId: string) {
// TODO: 查订单、校验状态/金额、创建 Payment 记录、调 wechat.createUnifiedOrder const order = await this.prisma.order.findUnique({ where: { id: orderId }, include: { payment: true } });
void this.wechat; // 占位引用,避免未使用告警 if (!order) throw new NotFoundException('订单不存在');
return this.prisma.payment.create({ if (order.userId !== userId) throw new ForbiddenException('无权支付该订单');
data: { order: { connect: { id: orderId } } }, 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<string>('wx.mchId') &&
this.config.get<string>('wx.mchApiV3Key') &&
this.config.get<string>('wx.mchSerialNo') &&
this.config.get<string>('wx.mchPrivateKeyPath'),
);
if (!configured) return { configured: false, message: '支付未配置' };
void this.wechat;
return { configured: true, message: '支付服务已配置但统一下单尚未接入' };
} }
/** 微信支付回调入口(本轮占位) */ /** 微信支付回调入口(本轮占位) */
+55 -1
View File
@@ -1,6 +1,6 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { Type } from 'class-transformer'; 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'; import { ProductStatus } from '@prisma/client';
export class CreateProductDto { export class CreateProductDto {
@@ -19,6 +19,60 @@ export class CreateProductDto {
@Min(0) @Min(0)
price!: number; 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<string, unknown>;
@ApiPropertyOptional({ type: Object })
@IsOptional()
@IsObject()
mask?: Record<string, unknown>;
@ApiPropertyOptional()
@IsOptional()
@IsString()
story?: string;
@ApiPropertyOptional()
@IsOptional()
@IsString()
scene?: string;
@ApiPropertyOptional()
@IsOptional()
@IsString()
iconImg?: string;
@ApiPropertyOptional({ description: '图片 URL 列表', type: [String] }) @ApiPropertyOptional({ description: '图片 URL 列表', type: [String] })
@IsOptional() @IsOptional()
@IsArray() @IsArray()
+15
View File
@@ -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;
}
+4 -3
View File
@@ -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 { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
import { Public } from '../common/decorators/public.decorator'; import { Public } from '../common/decorators/public.decorator';
import { ProductsService } from './products.service'; import { ProductsService } from './products.service';
import { CreateProductDto } from './dto/create-product.dto'; import { CreateProductDto } from './dto/create-product.dto';
import { ProductQueryDto } from './dto/product-query.dto';
@ApiTags('商品') @ApiTags('商品')
@ApiBearerAuth() @ApiBearerAuth()
@@ -13,8 +14,8 @@ export class ProductsController {
@Public() @Public()
@Get() @Get()
@ApiOperation({ summary: '在售商品列表' }) @ApiOperation({ summary: '在售商品列表' })
list() { list(@Query() query: ProductQueryDto) {
return this.productsService.list(); return this.productsService.list(query);
} }
@Public() @Public()
+65 -12
View File
@@ -1,27 +1,80 @@
import { Injectable } from '@nestjs/common'; import { Injectable, NotFoundException } from '@nestjs/common';
import { ProductStatus } from '@prisma/client'; import { Product, ProductStatus } from '@prisma/client';
import { PrismaService } from '../prisma/prisma.service'; import { PrismaService } from '../prisma/prisma.service';
import { CreateProductDto } from './dto/create-product.dto'; import { CreateProductDto } from './dto/create-product.dto';
import { ProductQueryDto } from './dto/product-query.dto';
type ProductResponse = Omit<Product, 'price' | 'originalPrice'> & {
price: number;
originalPrice?: number;
};
function serialize(product: Product): ProductResponse {
return {
...product,
price: Number(product.price),
originalPrice: product.originalPrice == null ? undefined : Number(product.originalPrice),
};
}
@Injectable() @Injectable()
export class ProductsService { export class ProductsService {
constructor(private readonly prisma: PrismaService) {} constructor(private readonly prisma: PrismaService) {}
async list() { async list(query: ProductQueryDto) {
// TODO: 分页、按分类/状态筛选、价格区间 const where = {
return this.prisma.product.findMany({ status: ProductStatus.ON_SALE,
where: { status: ProductStatus.ON_SALE }, ...(query.categoryId ? { categoryId: query.categoryId } : {}),
orderBy: { createdAt: 'desc' }, ...(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) { async findOne(id: string) {
// TODO: 404 处理、浏览量统计 const product = await this.prisma.product.findFirst({
return this.prisma.product.findUnique({ where: { id } }); where: { id, status: ProductStatus.ON_SALE },
});
if (!product) throw new NotFoundException('商品不存在');
return serialize(product);
} }
async create(dto: CreateProductDto) { async create(dto: CreateProductDto) {
// TODO: 校验 categoryId 存在、图片归属 const product = await this.prisma.product.create({
return this.prisma.product.create({ data: dto }); 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);
} }
} }