Compare commits
10
Commits
679e472570
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c07e03333c | ||
|
|
e5deacd19b | ||
|
|
aed7d12051 | ||
|
|
854ea103a9 | ||
|
|
46b150e3b8 | ||
|
|
17e4e7f560 | ||
|
|
c48e79802c | ||
|
|
05584172ca | ||
|
|
3e4c88768f | ||
|
|
d27753f732 |
@@ -0,0 +1,210 @@
|
|||||||
|
# 智绘微刻小程序 R1 前后端任务报告
|
||||||
|
|
||||||
|
> 报告日期:2026-09-14
|
||||||
|
> 关联前端:`wechat_wc`(Gitee)
|
||||||
|
> 关联后端:`wxmp_backend`(GitHub)
|
||||||
|
> 本报告用于说明当前小程序的整体功能、R1 商品目录工作、前后端联调结果以及可交付状态。
|
||||||
|
|
||||||
|
## 一、项目概况
|
||||||
|
|
||||||
|
“智绘微刻”是一个微信小程序定制商城。用户可以浏览激光微雕商品,查看商品详情,制作个性化设计,保存设计清单,填写收货地址并提交订单。后端负责统一保存商品、用户、地址、设计、订单和支付等业务数据,前端负责小程序页面展示和交互。
|
||||||
|
|
||||||
|
简单来说,整个项目是一条完整的定制购物链路:
|
||||||
|
|
||||||
|
**浏览商品 → 查看详情 → 进入定制 → 保存设计 → 选择地址 → 提交订单 → 查看订单状态。**
|
||||||
|
|
||||||
|
前端采用 Taro + React + TypeScript,可编译为微信小程序;后端采用 NestJS + Prisma,使用 PostgreSQL 保存业务数据,Redis 用于缓存或队列能力。开发环境通过 Docker Compose 统一运行,减少 Windows 与 Linux 部署环境之间的差异。
|
||||||
|
|
||||||
|
## 二、当前小程序已经具备的功能
|
||||||
|
|
||||||
|
### 1. 首页
|
||||||
|
|
||||||
|
- 小程序品牌标题和搜索入口。
|
||||||
|
- 定制成品展示区。
|
||||||
|
- 热门品类图片入口。
|
||||||
|
- 为你推荐商品区。
|
||||||
|
- 搜索商品时只显示搜索结果,自动隐藏成品展示、热门品类和推荐区,避免内容混杂。
|
||||||
|
- 商品价格、名称和图片优先来自后端接口,接口暂时不可用时保留本地缓存展示。
|
||||||
|
|
||||||
|
### 2. 商品目录与详情
|
||||||
|
|
||||||
|
- 商品列表页展示在售商品。
|
||||||
|
- 支持商品图片、名称、描述和价格展示。
|
||||||
|
- 商品详情页展示主图、名称、价格、原价、商品介绍、设计理念、使用场景、标签和规格参数。
|
||||||
|
- 商品详情页保留原有的沉浸式主图收束动画和滚动过渡。
|
||||||
|
- 顶部返回按钮和底部返回按钮均可返回商品列表。
|
||||||
|
- 商品列表和商品详情均已接入后端,而不是只依赖前端写死数据。
|
||||||
|
|
||||||
|
### 3. 个性化定制
|
||||||
|
|
||||||
|
- 定制页面支持词云、底图和贴纸等设计元素。
|
||||||
|
- 支持贴纸编辑、位置调整、缩放和旋转。
|
||||||
|
- 设计数据按统一结构保存,可供后续生产投递使用。
|
||||||
|
- 支持从设计清单继续编辑。
|
||||||
|
|
||||||
|
### 4. 设计清单
|
||||||
|
|
||||||
|
- 查看个人设计清单。
|
||||||
|
- 按“未设计、设计中、生产中、已下单”等状态查看。
|
||||||
|
- 进入设计、继续设计和批量删除。
|
||||||
|
- 设计数据支持本地暂存与后端同步。
|
||||||
|
|
||||||
|
### 5. 地址与订单
|
||||||
|
|
||||||
|
- 收货地址新增、修改、删除和设置默认地址。
|
||||||
|
- 结算页展示设计预览、商品数量、价格和收货地址。
|
||||||
|
- 下单时由后端根据真实商品价格重新计算金额,避免只相信前端传来的价格。
|
||||||
|
- 订单列表、订单详情、订单状态和支付入口已经接入项目流程。
|
||||||
|
|
||||||
|
### 6. 用户与其他页面
|
||||||
|
|
||||||
|
- 微信登录、用户资料和个人中心。
|
||||||
|
- 订单、设计清单、地址、设置、协议和客服页面。
|
||||||
|
- 词云生成任务、任务状态查询和结果获取接口。
|
||||||
|
- OSS 图片资源和上传凭证接口。
|
||||||
|
|
||||||
|
## 三、R1 商品目录工作内容
|
||||||
|
|
||||||
|
R1 的核心不是单独做一个静态目录页面,而是建立“商品目录数据通路”:后端提供统一商品数据,前端通过接口读取并展示,其他成员的设计、订单和生产功能都使用同一份商品信息。
|
||||||
|
|
||||||
|
### 后端已完成
|
||||||
|
|
||||||
|
- 商品分类数据模型和分类列表接口。
|
||||||
|
- 商品数据模型、在售状态和排序字段。
|
||||||
|
- 商品列表接口,支持分页、分类筛选和关键词搜索。
|
||||||
|
- 商品详情接口,只返回在售商品。
|
||||||
|
- 创建商品接口,供管理后台或初始化数据使用。
|
||||||
|
- DTO 参数校验、分页上限和统一金额输出。
|
||||||
|
- Prisma 数据库迁移和种子数据。
|
||||||
|
- Swagger 接口说明和 `api-contract-v1.md` 契约同步。
|
||||||
|
|
||||||
|
### 前端已完成
|
||||||
|
|
||||||
|
- 首页、商品列表页调用 `fetchProducts`。
|
||||||
|
- 商品详情页调用 `fetchProduct`。
|
||||||
|
- 增加后端字段到前端商品模型的适配处理。
|
||||||
|
- 服务端字段不完整时,使用本地商品资料补齐介绍、规格、标签和图片。
|
||||||
|
- 商品价格、名称和图片不再由页面单独维护,减少前后端数据不一致。
|
||||||
|
|
||||||
|
### R1 对外接口
|
||||||
|
|
||||||
|
| 接口 | 用途 | 状态 |
|
||||||
|
|---|---|---|
|
||||||
|
| `GET /api/categories` | 获取商品分类 | 已完成 |
|
||||||
|
| `GET /api/products` | 获取在售商品列表,支持分页、分类和关键词 | 已完成 |
|
||||||
|
| `GET /api/products/:id` | 获取商品详情 | 已完成 |
|
||||||
|
| `POST /api/products` | 创建商品,管理端使用 | 已完成 |
|
||||||
|
|
||||||
|
接口契约中的基础路径、鉴权规则、分页格式、金额格式和错误码保持统一,后续成员可直接按契约消费,不需要各自猜测字段。
|
||||||
|
|
||||||
|
## 四、前后端联调与验证结果
|
||||||
|
|
||||||
|
本项目已经进行过本机容器化联调和前端编译验证。
|
||||||
|
|
||||||
|
### 已验证内容
|
||||||
|
|
||||||
|
1. Docker Compose 可以启动后端、PostgreSQL 和 Redis 容器。
|
||||||
|
2. 后端健康检查可用,Swagger 文档可以打开。
|
||||||
|
3. 商品列表接口和商品详情接口可以返回商品数据。
|
||||||
|
4. 前端商品列表和详情页能够读取后端数据。
|
||||||
|
5. 修改后端数据库中的商品价格后,重新编译并刷新前端,页面价格同步变化。
|
||||||
|
6. 该价格变化证明前端不是只显示写死数据,前后端数据通路已经打通。
|
||||||
|
7. 商品详情页的介绍文本、主图、价格和滚动过渡已恢复并编译通过。
|
||||||
|
8. 自定义底部栏增加了独立点击入口,降低不同微信基础库下点击失效的概率。
|
||||||
|
|
||||||
|
### 当前验证结论
|
||||||
|
|
||||||
|
商品目录接口已经经过实际联调,可以作为其他成员开发设计清单、订单和生产流程时的共同数据来源。前端和后端的目录字段能够对齐,商品信息修改能够传递到页面,R1 商品目录部分达到可交付状态。
|
||||||
|
|
||||||
|
## 五、工作量概览
|
||||||
|
|
||||||
|
工作量不仅包括写几个接口,还包括数据模型、接口契约、种子数据、容器化环境、前端适配和联调验证。
|
||||||
|
|
||||||
|
### 后端工作量
|
||||||
|
|
||||||
|
- 商品分类和商品数据结构设计。
|
||||||
|
- Prisma schema 与迁移脚本。
|
||||||
|
- 商品列表、详情和创建接口。
|
||||||
|
- 分页、关键词、分类筛选和在售状态过滤。
|
||||||
|
- DTO 校验和金额类型转换。
|
||||||
|
- 商品种子数据维护。
|
||||||
|
- Docker 开发镜像和 Compose 联调配置。
|
||||||
|
- 接口契约、对接说明和验证说明文档。
|
||||||
|
|
||||||
|
R1 商品目录实现涉及约 15 个后端文件,包含接口、服务、DTO、数据库迁移、种子数据、Docker 配置和文档。
|
||||||
|
|
||||||
|
### 前端工作量
|
||||||
|
|
||||||
|
- 首页商品数据接入。
|
||||||
|
- 商品列表页和详情页数据接入。
|
||||||
|
- 前后端商品字段适配及本地兜底。
|
||||||
|
- 商品搜索结果布局优化。
|
||||||
|
- 热门品类改为图片入口,降低图标入口的识别成本。
|
||||||
|
- 商品详情页介绍文本和规格信息补齐。
|
||||||
|
- 沉浸式详情页滚动过渡恢复和节流优化。
|
||||||
|
- 详情页返回逻辑兼容处理。
|
||||||
|
- 自定义底部栏点击逻辑增强。
|
||||||
|
- 微信小程序构建产物更新。
|
||||||
|
|
||||||
|
前端 R1 相关改动覆盖首页、商品列表、商品详情、商品接口适配、自定义底部栏及对应编译产物。
|
||||||
|
|
||||||
|
## 六、项目的独特性和提升点
|
||||||
|
|
||||||
|
### 1. 商品数据真正统一
|
||||||
|
|
||||||
|
商品价格、图片、描述和规格由后端统一维护,首页、商品目录、详情、定制和订单可以使用同一商品记录。后续调整价格或下架商品时,不需要逐个修改前端页面。
|
||||||
|
|
||||||
|
### 2. 面向定制生产,而不是普通电商展示
|
||||||
|
|
||||||
|
商品信息不仅用于展示,还与设计数据、词云结果、贴纸和后续生产投递关联。设计清单中的数据结构为后续生成生产文件保留了统一入口。
|
||||||
|
|
||||||
|
### 3. 前端具备离线兜底能力
|
||||||
|
|
||||||
|
当开发环境接口暂时不可用时,页面仍可以使用本地商品配置展示,方便开发调试;接口恢复后再以服务端数据为准。
|
||||||
|
|
||||||
|
### 4. 详情页具有沉浸式交互
|
||||||
|
|
||||||
|
商品详情页不是简单的静态表格,而是通过主图收束、标题接替、内容渐入和滚动节流,形成更接近实物定制展示的浏览体验。
|
||||||
|
|
||||||
|
### 5. 开发环境可迁移
|
||||||
|
|
||||||
|
Docker Compose 将 Node、PostgreSQL 和 Redis 组合在一起,本机 Windows 环境可以模拟 Linux 容器运行方式,后续迁移到服务器时主要调整环境变量和反向代理配置。
|
||||||
|
|
||||||
|
### 6. 契约优先的多人协作
|
||||||
|
|
||||||
|
前后端以 `api-contract-v1.md` 为共同依据,接口字段、金额、分页、鉴权和错误码都有明确约定。R2、R3、R4 成员可以在不复制整套项目的情况下,直接消费 R1 商品接口。
|
||||||
|
|
||||||
|
## 七、代码交付情况
|
||||||
|
|
||||||
|
### 前端
|
||||||
|
|
||||||
|
- 远程仓库:Gitee `lhmin0604/wechat_wc`
|
||||||
|
- 分支:`feat/r1-catalog`
|
||||||
|
- 最新提交:`99bcf4a fix(ui): restore tab bar and detail navigation`
|
||||||
|
- 状态:已推送,工作区干净
|
||||||
|
|
||||||
|
### 后端
|
||||||
|
|
||||||
|
- 远程仓库:GitHub `obroccolio/wxmp_backend`
|
||||||
|
- 分支:`feat/r1-catalog`
|
||||||
|
- 最新提交:`d27753f chore: snapshot handoff documents before home search redesign`
|
||||||
|
- 状态:已推送,工作区干净
|
||||||
|
|
||||||
|
两个仓库均保留独立分支,其他成员可以先拉取分支检查,再合并到各自主干,不会覆盖主干历史。
|
||||||
|
|
||||||
|
## 八、仍需在上线前确认的事项
|
||||||
|
|
||||||
|
以下内容不影响 R1 商品目录已经完成,但在真机和正式上线前仍需确认:
|
||||||
|
|
||||||
|
- 微信公众平台 AppID、AppSecret 与后端环境变量必须属于同一个小程序。
|
||||||
|
- 真机不能访问本机 `127.0.0.1`,需要使用 HTTPS 域名、FRP 或服务器反向代理。
|
||||||
|
- 微信公众平台需要配置后端接口合法域名和 OSS 图片合法域名。
|
||||||
|
- 生产环境需要使用正式数据库、Redis、OSS 和 JWT 密钥,不能沿用本机测试值。
|
||||||
|
- 真机测试时应再次检查首页、商品列表、商品详情、设计清单、地址和订单请求是否返回 200。
|
||||||
|
|
||||||
|
## 九、最终结论
|
||||||
|
|
||||||
|
当前项目已经形成一个可运行的定制商城小程序雏形,前端页面、后端业务接口、数据库、Docker 开发环境和多人协作契约已经连接起来。
|
||||||
|
|
||||||
|
R1 商品目录的核心目标已经完成:后端提供可复用的商品分类、商品列表和商品详情接口,前端已经完成真实接口对接,并通过修改后端商品价格后前端同步变化的方式验证了数据通路。该接口可以交给其他成员继续用于设计清单、订单和生产流程开发。
|
||||||
|
|
||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -151,6 +151,10 @@
|
|||||||
|
|
||||||
返回当前用户设计清单,按 `createdAt` 倒序。
|
返回当前用户设计清单,按 `createdAt` 倒序。
|
||||||
|
|
||||||
|
### GET /api/design-list/:id
|
||||||
|
|
||||||
|
返回单条设计清单(后端已实现 `@Get(':id')`;R3 结算页 `fetchDesign` 消费)。仅本人可查,越权 403 / 不存在 404。
|
||||||
|
|
||||||
### POST /api/design-list
|
### POST /api/design-list
|
||||||
|
|
||||||
请求体:
|
请求体:
|
||||||
|
|||||||
@@ -0,0 +1,4 @@
|
|||||||
|
-- 订单幂等键(api-contract-v1 §6:requestId 防重复下单,重复请求返回已创建订单)
|
||||||
|
-- 允许已有 R1/R3 本地库安全接入 R2:字段已存在时不应中断整套迁移。
|
||||||
|
ALTER TABLE "Order" ADD COLUMN IF NOT EXISTS "requestId" TEXT;
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS "Order_requestId_key" ON "Order"("requestId");
|
||||||
@@ -138,13 +138,13 @@ 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?
|
|
||||||
// 待付款订单的服务端截止时间
|
// 待付款订单的服务端截止时间
|
||||||
paymentExpiresAt DateTime?
|
paymentExpiresAt DateTime?
|
||||||
paidAt DateTime?
|
paidAt DateTime?
|
||||||
// 关联的设计清单(R4 下单后 WCD 派单据此读取 designData)
|
// 关联的设计清单(R4 下单后 WCD 派单据此读取 designData)
|
||||||
designListId String?
|
designListId String?
|
||||||
|
// 客户端幂等键(api-contract-v1 §6:requestId 重复请求返回已创建订单)
|
||||||
|
requestId String? @unique
|
||||||
user User @relation(fields: [userId], references: [id])
|
user User @relation(fields: [userId], references: [id])
|
||||||
designList DesignList? @relation(fields: [designListId], references: [id])
|
designList DesignList? @relation(fields: [designListId], references: [id])
|
||||||
items OrderItem[]
|
items OrderItem[]
|
||||||
|
|||||||
@@ -1,12 +1,11 @@
|
|||||||
import { ConflictException, ForbiddenException, Injectable, NotFoundException, BadRequestException } from '@nestjs/common';
|
import { BadRequestException, ConflictException, ForbiddenException, Injectable, NotFoundException } from '@nestjs/common';
|
||||||
import { OrderStatus, Prisma } from '@prisma/client';
|
import { DesignListStatus, 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';
|
import { OrderQueryDto } from './dto/order-query.dto';
|
||||||
|
|
||||||
const PAYMENT_TIMEOUT_MS = 30 * 60 * 1000;
|
const PAYMENT_TIMEOUT_MS = 30 * 60 * 1000;
|
||||||
|
|
||||||
// 订单号生成:日期 + 随机后缀(无 Math.random 依赖要求仅限 workflow 脚本,普通运行时可用)
|
|
||||||
function generateOrderNo(): string {
|
function generateOrderNo(): string {
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
const ymd =
|
const ymd =
|
||||||
@@ -40,7 +39,13 @@ export class OrdersService {
|
|||||||
await this.expirePendingOrders({ userId });
|
await this.expirePendingOrders({ userId });
|
||||||
const where = { userId, ...(query.status ? { status: query.status } : {}) };
|
const where = { userId, ...(query.status ? { status: query.status } : {}) };
|
||||||
const [rows, total] = await this.prisma.$transaction([
|
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.findMany({
|
||||||
|
where,
|
||||||
|
include: { items: true, payment: true },
|
||||||
|
orderBy: { createdAt: 'desc' },
|
||||||
|
skip: (query.page - 1) * query.pageSize,
|
||||||
|
take: query.pageSize,
|
||||||
|
}),
|
||||||
this.prisma.order.count({ where }),
|
this.prisma.order.count({ where }),
|
||||||
]);
|
]);
|
||||||
return { list: rows.map((row) => this.serialize(row)), total, page: query.page, pageSize: query.pageSize };
|
return { list: rows.map((row) => this.serialize(row)), total, page: query.page, pageSize: query.pageSize };
|
||||||
@@ -56,21 +61,46 @@ export class OrdersService {
|
|||||||
|
|
||||||
async create(userId: string, dto: CreateOrderDto) {
|
async create(userId: string, dto: CreateOrderDto) {
|
||||||
if (dto.requestId) {
|
if (dto.requestId) {
|
||||||
const existing = await this.prisma.order.findFirst({ where: { userId, requestId: dto.requestId }, include: { items: true, payment: true } });
|
const existing = await this.prisma.order.findFirst({
|
||||||
|
where: { userId, requestId: dto.requestId },
|
||||||
|
include: { items: true, payment: true },
|
||||||
|
});
|
||||||
if (existing) return this.serialize(existing);
|
if (existing) return this.serialize(existing);
|
||||||
}
|
}
|
||||||
|
|
||||||
const address = await this.prisma.address.findUnique({ where: { id: dto.addressId } });
|
const address = await this.prisma.address.findUnique({ where: { id: dto.addressId } });
|
||||||
if (!address) throw new NotFoundException('地址不存在');
|
if (!address) throw new NotFoundException('地址不存在');
|
||||||
if (address.userId !== userId) throw new ForbiddenException('无权使用该地址');
|
if (address.userId !== userId) throw new ForbiddenException('无权使用该地址');
|
||||||
const design = dto.designListId ? await this.prisma.designList.findUnique({ where: { id: dto.designListId } }) : null;
|
|
||||||
|
const design = dto.designListId
|
||||||
|
? await this.prisma.designList.findUnique({ where: { id: dto.designListId } })
|
||||||
|
: null;
|
||||||
if (dto.designListId && !design) throw new NotFoundException('设计清单不存在');
|
if (dto.designListId && !design) throw new NotFoundException('设计清单不存在');
|
||||||
if (design && design.userId !== userId) throw new ForbiddenException('无权使用该设计清单');
|
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 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 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 total = dto.items.reduce(
|
||||||
const addressSnapshot = { id: address.id, name: address.name, phone: address.phone, province: address.province, city: address.city, district: address.district, detail: address.detail };
|
(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);
|
const paymentExpiresAt = new Date(Date.now() + PAYMENT_TIMEOUT_MS);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const created = await this.prisma.$transaction(async (tx) => {
|
const created = await this.prisma.$transaction(async (tx) => {
|
||||||
let orderNo = generateOrderNo();
|
let orderNo = generateOrderNo();
|
||||||
@@ -79,7 +109,8 @@ export class OrdersService {
|
|||||||
if (!collision) break;
|
if (!collision) break;
|
||||||
orderNo = generateOrderNo();
|
orderNo = generateOrderNo();
|
||||||
}
|
}
|
||||||
return tx.order.create({
|
|
||||||
|
const order = await tx.order.create({
|
||||||
data: {
|
data: {
|
||||||
orderNo,
|
orderNo,
|
||||||
user: { connect: { id: userId } },
|
user: { connect: { id: userId } },
|
||||||
@@ -88,16 +119,39 @@ export class OrdersService {
|
|||||||
paymentExpiresAt,
|
paymentExpiresAt,
|
||||||
addressSnapshot: addressSnapshot as Prisma.InputJsonValue,
|
addressSnapshot: addressSnapshot as Prisma.InputJsonValue,
|
||||||
designList: dto.designListId ? { connect: { id: dto.designListId } } : undefined,
|
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 })) },
|
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' } },
|
payment: { create: { status: 'PENDING' } },
|
||||||
},
|
},
|
||||||
include: { items: true, payment: true },
|
include: { items: true, payment: true },
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (dto.designListId) {
|
||||||
|
await tx.designList.updateMany({
|
||||||
|
where: {
|
||||||
|
id: dto.designListId,
|
||||||
|
status: { in: [DesignListStatus.DRAFT, DesignListStatus.SUBMITTED] },
|
||||||
|
},
|
||||||
|
data: { status: DesignListStatus.PROCESSING },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return order;
|
||||||
});
|
});
|
||||||
return this.serialize(created);
|
return this.serialize(created);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof Prisma.PrismaClientKnownRequestError && error.code === 'P2002') {
|
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;
|
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);
|
if (existing) return this.serialize(existing);
|
||||||
throw new ConflictException('订单号冲突,请重试');
|
throw new ConflictException('订单号冲突,请重试');
|
||||||
}
|
}
|
||||||
@@ -108,13 +162,32 @@ export class OrdersService {
|
|||||||
async cancel(userId: string, id: string) {
|
async cancel(userId: string, id: string) {
|
||||||
const order = await this.getOwned(userId, id);
|
const order = await this.getOwned(userId, id);
|
||||||
if (order.status !== OrderStatus.PENDING) throw new ConflictException('仅待付款订单可取消');
|
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 } }));
|
const updated = await this.prisma.order.update({
|
||||||
|
where: { id },
|
||||||
|
data: { status: OrderStatus.CANCELLED },
|
||||||
|
include: { items: true, payment: true },
|
||||||
|
});
|
||||||
|
return this.serialize(updated);
|
||||||
}
|
}
|
||||||
|
|
||||||
async confirm(userId: string, id: string) {
|
async confirm(userId: string, id: string) {
|
||||||
const order = await this.getOwned(userId, id);
|
const order = await this.getOwned(userId, id);
|
||||||
if (order.status !== OrderStatus.SHIPPED) throw new ConflictException('仅待收货订单可确认收货');
|
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 } }));
|
const updated = await this.prisma.$transaction(async (tx) => {
|
||||||
|
const completed = await tx.order.update({
|
||||||
|
where: { id },
|
||||||
|
data: { status: OrderStatus.COMPLETED },
|
||||||
|
include: { items: true, payment: true },
|
||||||
|
});
|
||||||
|
if (order.designListId) {
|
||||||
|
await tx.designList.updateMany({
|
||||||
|
where: { id: order.designListId, status: DesignListStatus.PROCESSING },
|
||||||
|
data: { status: DesignListStatus.DONE },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return completed;
|
||||||
|
});
|
||||||
|
return this.serialize(updated);
|
||||||
}
|
}
|
||||||
|
|
||||||
private async getOwned(userId: string, id: string) {
|
private async getOwned(userId: string, id: string) {
|
||||||
@@ -125,10 +198,6 @@ export class OrdersService {
|
|||||||
return order;
|
return order;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* 过期状态由服务端收口,而不是只依赖小程序倒计时。这样用户切后台、换设备或
|
|
||||||
* 直接调用支付接口时,订单仍无法绕过 30 分钟限制。
|
|
||||||
*/
|
|
||||||
private async expirePendingOrders(where: Prisma.OrderWhereInput) {
|
private async expirePendingOrders(where: Prisma.OrderWhereInput) {
|
||||||
await this.prisma.order.updateMany({
|
await this.prisma.order.updateMany({
|
||||||
where: {
|
where: {
|
||||||
|
|||||||
Reference in New Issue
Block a user