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
{
"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
+1 -1
View File
@@ -60,7 +60,7 @@
"typescript": "^5.6.0"
},
"prisma": {
"seed": "ts-node prisma/seed.ts"
"seed": "node prisma/seed.js"
},
"engines": {
"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
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 {
+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());
+19 -29
View File
@@ -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()
+11 -2
View File
@@ -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);
}
+14 -3
View File
@@ -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,
};
}
/**
+8 -3
View File
@@ -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) {
+16 -15
View File
@@ -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<string, unknown>;
@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;
}
+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 { 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);
}
}
+115 -33
View File
@@ -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 派单读取 designDataR3 契约 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 },
});
}
}
+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 { 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()
+26 -7
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 { 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<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 { 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<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] })
@IsOptional()
@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 { 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()
+65 -12
View File
@@ -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<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()
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);
}
}