feat(r3): complete order and payment flow
This commit is contained in:
+5
-10
@@ -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
@@ -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");
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
@@ -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()
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
@@ -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 },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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: '支付服务已配置但统一下单尚未接入' };
|
||||
}
|
||||
|
||||
/** 微信支付回调入口(本轮占位) */
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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()
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user