From 21e3ece21cb6bd9c907be1e267c939810b44d077 Mon Sep 17 00:00:00 2001 From: richarjiang Date: Fri, 25 Sep 2026 11:22:15 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=8E=BB=E6=8E=89=E5=95=86=E5=93=81?= =?UTF-8?q?=E5=8F=96=E8=B4=A7=E7=A0=81=EF=BC=8C=E7=AE=A1=E7=90=86=E5=91=98?= =?UTF-8?q?=E5=8F=AF=E7=9B=B4=E6=8E=A5=E5=8F=91=E8=B4=A7=E5=AE=8C=E6=88=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 已付款订单不再生成或核验取货码,管理员确认后即可完成订单。 Co-authored-by: Cursor --- docs/studio-shop.md | 12 +++---- packages/app/src/components/ShopOrders.vue | 36 +++++-------------- packages/app/src/pages/admin/product-edit.vue | 2 +- packages/app/src/pages/admin/products.vue | 4 +-- packages/app/src/pages/shop/detail.vue | 2 +- packages/app/src/pages/shop/orders.vue | 2 +- packages/app/src/pages/shop/shop.css | 8 ----- packages/app/src/utils/shop.ts | 4 +-- .../migration.sql | 3 ++ packages/server/prisma/schema.prisma | 1 - .../src/shop/__tests__/shop.service.spec.ts | 11 +++--- packages/server/src/shop/dto/shop.dto.ts | 3 -- packages/server/src/shop/shop.controller.ts | 5 ++- packages/server/src/shop/shop.service.ts | 9 +++-- packages/shared/src/types/shop.ts | 1 - 15 files changed, 35 insertions(+), 68 deletions(-) create mode 100644 packages/server/prisma/migrations/20260925111500_drop_product_order_pickup_code/migration.sql diff --git a/docs/studio-shop.md b/docs/studio-shop.md index 3d0c69c..10d8d2c 100644 --- a/docs/studio-shop.md +++ b/docs/studio-shop.md @@ -8,8 +8,8 @@ - 各尺码、颜色按独立商品管理,在标题中标明规格;不引入购物车、物流、优惠券或复杂 SKU 系统。 - 创建商品默认库存为 0;保存后通过库存调整补货。调整数量为有符号整数,必须填写原因。 - 顾客填写取货人、手机号及数量,确认购买后微信付款。价格变化时先阻止下单,要求确认最新价格。 -- 付款成功后显示提货码。管理员核对商品数量和取货人,输入顾客的 12 位提货码并确认交付。 -- 管理端支持商品搜索、订单状态筛选、商品名 / 订单号 / 手机号搜索、分页、累计成交额、销量、待取货数、低库存提示。 +- 付款成功后订单进入待发货。管理员核对商品数量和取货人后,直接确认发货并完成订单。 +- 管理端支持商品搜索、订单状态筛选、商品名 / 订单号 / 手机号搜索、分页、累计成交额、销量、待发货数、低库存提示。 - 售后通过详情页「联系场馆」沟通;本版不含线上退款流程。不能把已付款订单当作未付款订单取消。若后续引入商户平台退款,需配套退款对账及库存回补逻辑,不能仅修改订单状态。 ## 现有架构与接入方式 @@ -33,9 +33,9 @@ 4. 客户端支付成功不直接更新订单。服务端查询微信,核对商户、应用、订单号、金额、币种、openid 和微信交易号,才能从 PENDING 更新为 PAID。 5. 商品回调独立使用 `/api/shop/wx-notify`,只接受 AES-GCM 解密后的资源,再查单确认,不依赖旧会员卡回调的验签占位实现。成功返回 HTTP 204,绕过统一 API 包装。 6. 定时任务每 30 秒按最近对账时间分批扫描待付款订单,支持漏回调恢复;用户也可刷新支付状态。失败会保留库存并重试。 -7. 取消订单先查单、关单,关单成功后才原子释放库存;若先查到支付成功则转为待取货。关单失败绝不提前释放。 +7. 取消订单先查单、关单,关单成功后才原子释放库存;若先查到支付成功则转为待发货。关单失败绝不提前释放。 8. 微信尚无订单时,可能存在正在途中的预支付请求;过期后再等待 60 秒才释放,避免“取消释放后又支付”。因此用户从未成功调起支付时,取消可能提示等待,最晚由超时任务回收(微信服务异常期间会继续保留并重试)。 -9. 状态更新带条件,重复回调、重复取消或并发扫描不会重复计算销量、重复返还库存。核销仅允许 PAID + 正确提货码,并记录操作人/时间。 +9. 状态更新带条件,重复回调、重复取消或并发扫描不会重复计算销量、重复返还库存。发货完成仅允许已付款订单,并记录操作人/时间。 10. 管理员保存商品和库存均校验版本;发生竞争时提示刷新。库存调整记录最近 50 条人工调整原因及操作人;系统预占/释放由订单本身追溯。 状态:`PENDING → PAID → COMPLETED`;`PENDING → CANCELLED`。商品可下架但不删除,保证历史订单有效。 @@ -60,12 +60,12 @@ 4. 沿用微信支付配置和 COS 配置,无新增必填环境变量。确保 `API_BASE_URL` 指向服务域名、外网能访问 `/api/shop/wx-notify`,调度进程正常运行。 5. 构建发布小程序 `pnpm build:app`;新页面仍调用既有线上 API 域名,必须先上线后端。 6. 管理员创建真实商品、上传封面、补货、上架。没有硬编码测试商品写入数据库。 -7. 在微信真机执行一笔小额购买:取消支付后续付、查看服务端支付确认、提货码核销、超时关单及库存回补。退款不在本版本内。 +7. 在微信真机执行一笔小额购买:取消支付后续付、查看服务端支付确认、管理员直接发货完成、超时关单及库存回补。退款不在本版本内。 ## 验证与限制 - 共享包、服务端构建;uni-app 类型检查;微信小程序构建;Prisma schema validate;Git diff 空白检查。 -- Jest 覆盖价格变更、越权归属、负数/小数/非法输入、请求幂等、最后库存竞争、支付金额及付款人核对、重复回调、关单失败、过期释放、版本冲突、核销状态和微信 GET 签名规范。 +- Jest 覆盖价格变更、越权归属、负数/小数/非法输入、请求幂等、最后库存竞争、支付金额及付款人核对、重复回调、关单失败、过期释放、版本冲突、发货完成状态和微信 GET 签名规范。 - 全量 Jest 覆盖率运行:26 个套件、418 项测试通过(新增 60 项测试);并发测试基于条件更新行为模拟,不能替代真实 MySQL 多连接并发验证。 - 用本地独立测试数据在 390px 手机尺寸 H5 预览检查列表、详情、管理汇总、编辑页面。测试图片为标注 PREVIEW 的占位插画,未上传 COS、未接触线上商品。 - 已完成生产数据库迁移、服务端部署和线上接口冒烟验证。尚未执行真实微信扣款、COS 实际上传或微信真机验收;小程序新版仍需发布。 diff --git a/packages/app/src/components/ShopOrders.vue b/packages/app/src/components/ShopOrders.vue index 2cf36c1..3b96854 100644 --- a/packages/app/src/components/ShopOrders.vue +++ b/packages/app/src/components/ShopOrders.vue @@ -45,21 +45,9 @@ {{ order.contactName }} · {{ order.contactPhone }} 订单号 {{ order.orderNo }} ⧉ - + + 已付款,到馆即可领取。工作人员确认交付后,订单即完成。 + 付款截止 {{ date(order.expiresAt) }},超时后自动取消。 @@ -77,7 +65,7 @@ :disabled="!!busy" @tap="complete(order)" > - 确认已交付商品 + 确认发货并完成 @@ -105,8 +93,7 @@ const items = ref([]), total = ref(0), loading = ref(false), error = ref(''), - busy = ref(''), - codes = ref>({}) + busy = ref('') const tabs = [ { value: '', label: '全部' }, ...Object.entries(shopStatus).map(([value, label]) => ({ value, label })), @@ -199,22 +186,17 @@ async function cancel(order: ShopOrder) { } } async function complete(order: ShopOrder) { - const code = (codes.value[order.id] || '').trim().toUpperCase() - if (!/^[A-F0-9]{12}$/.test(code)) { - shopError(new Error('请输入完整提货码')) - return - } const answer = await uni.showModal({ - title: '确认商品已交付', - content: `${order.title} × ${order.quantity},取货人 ${order.contactName}。核销后订单将完成。`, + title: '确认发货并完成', + content: `${order.title} × ${order.quantity},取货人 ${order.contactName}。确认后订单将完成。`, }) if (!answer.confirm || busy.value) return busy.value = order.id try { - await post(`/admin/shop/orders/${order.id}/complete`, { pickupCode: code }) + await post(`/admin/shop/orders/${order.id}/complete`) await load(true) emit('changed') - uni.showToast({ title: '提货完成' }) + uni.showToast({ title: '订单已完成' }) } catch (e) { shopError(e) } finally { diff --git a/packages/app/src/pages/admin/product-edit.vue b/packages/app/src/pages/admin/product-edit.vue index f7d2c7c..758e200 100644 --- a/packages/app/src/pages/admin/product-edit.vue +++ b/packages/app/src/pages/admin/product-edit.vue @@ -43,7 +43,7 @@ 上架展示 - 下架不影响已有订单的提货 + 下架不影响已付款订单 diff --git a/packages/app/src/pages/admin/products.vue b/packages/app/src/pages/admin/products.vue index 6ddb943..3cf18be 100644 --- a/packages/app/src/pages/admin/products.vue +++ b/packages/app/src/pages/admin/products.vue @@ -14,7 +14,7 @@ {{ summary.orders }} 笔已付款 · {{ summary.quantity }} 件商品 - {{ summary.pendingPickup }} 待取货 + {{ summary.pendingPickup }} 待发货 {{ summary.activeProducts }} 已上架 低库存 {{ summary.lowStock }} 款 @@ -23,7 +23,7 @@ 商品与库存 - 订单与提货 + 销售订单 可售 {{ product.stock }} 件 · 已售 {{ product.sold }} 件 - 到馆自提 · 付款后凭订单提货码领取 + 到馆自提 · 付款后由工作人员确认交付 {{ studio.studioInfo?.address || '到馆领取前可联系场馆确认营业时间' }} · 联系场馆 › diff --git a/packages/app/src/pages/shop/orders.vue b/packages/app/src/pages/shop/orders.vue index 0d602ac..374993f 100644 --- a/packages/app/src/pages/shop/orders.vue +++ b/packages/app/src/pages/shop/orders.vue @@ -4,7 +4,7 @@ YOUR STUDIO FINDS 把喜欢带回家 - 付款后到馆领取,提货码在订单内查看。 + 付款后到馆领取,工作人员确认交付后订单完成。 登录后查看你的商品订单 diff --git a/packages/app/src/pages/shop/shop.css b/packages/app/src/pages/shop/shop.css index 5729e4d..7058414 100644 --- a/packages/app/src/pages/shop/shop.css +++ b/packages/app/src/pages/shop/shop.css @@ -156,14 +156,6 @@ button::after { color: #a09487; word-break: break-all; } -.code { - font-family: monospace; - font-size: 38rpx; - letter-spacing: 4rpx; - color: #547060; - margin: 18rpx 0; - display: block; -} .load-more { margin: 24rpx auto; } diff --git a/packages/app/src/utils/shop.ts b/packages/app/src/utils/shop.ts index d5e7a4c..85d296f 100644 --- a/packages/app/src/utils/shop.ts +++ b/packages/app/src/utils/shop.ts @@ -3,8 +3,8 @@ import { post } from './request' export const shopMoney = (fen: number) => (fen / 100).toFixed(2) export const shopStatus: Record = { PENDING: '待付款', - PAID: '待到馆取货', - COMPLETED: '已取货', + PAID: '待发货', + COMPLETED: '已完成', CANCELLED: '已取消', } export const shopError = (error: unknown) => diff --git a/packages/server/prisma/migrations/20260925111500_drop_product_order_pickup_code/migration.sql b/packages/server/prisma/migrations/20260925111500_drop_product_order_pickup_code/migration.sql new file mode 100644 index 0000000..e6e356b --- /dev/null +++ b/packages/server/prisma/migrations/20260925111500_drop_product_order_pickup_code/migration.sql @@ -0,0 +1,3 @@ +-- Paid product orders are fulfilled directly by an administrator. +-- The pickup code is no longer generated, shown, or required. +ALTER TABLE `product_orders` DROP COLUMN `pickup_code`; diff --git a/packages/server/prisma/schema.prisma b/packages/server/prisma/schema.prisma index cad8dbf..19018db 100644 --- a/packages/server/prisma/schema.prisma +++ b/packages/server/prisma/schema.prisma @@ -663,7 +663,6 @@ model ProductOrder { contactName String @map("contact_name") @db.VarChar(40) contactPhone String @map("contact_phone") @db.VarChar(20) status ProductOrderStatus @default(PENDING) - pickupCode String @map("pickup_code") @db.VarChar(12) wxTransactionId String? @unique @map("wx_transaction_id") expiresAt DateTime @map("expires_at") paidAt DateTime? @map("paid_at") diff --git a/packages/server/src/shop/__tests__/shop.service.spec.ts b/packages/server/src/shop/__tests__/shop.service.spec.ts index 23a02d8..424e26e 100644 --- a/packages/server/src/shop/__tests__/shop.service.spec.ts +++ b/packages/server/src/shop/__tests__/shop.service.spec.ts @@ -15,7 +15,6 @@ const order = () => ({ contactName: '顾客', contactPhone: '13800138000', status: 'PENDING', - pickupCode: 'ABCDEF123456', expiresAt: new Date(Date.now() + 900000), createdAt: new Date(), updatedAt: new Date(), @@ -78,7 +77,6 @@ describe('ShopService inventory and payment', () => { create: jest.fn(async ({ data }) => ({ ...current, ...data })), updateMany: jest.fn(async ({ where, data }) => { if (where.status && where.status !== current.status) return { count: 0 } - if (where.pickupCode && where.pickupCode !== current.pickupCode) return { count: 0 } Object.assign(current, data) return { count: 1 } }), @@ -246,14 +244,13 @@ describe('ShopService inventory and payment', () => { data: { productId: 'p1', delta: 2, reason: '到货', operatorId: 'admin' }, }) }) - it('requires correct pickup code and paid state', async () => { - await expect(service.complete('o1', current.pickupCode, 'admin')).rejects.toThrow('不可核销') + it('completes a paid order directly and only once', async () => { + await expect(service.complete('o1', 'admin')).rejects.toThrow('仅已付款') current.status = 'PAID' - await expect(service.complete('o1', 'WRONG', 'admin')).rejects.toThrow('不可核销') - await service.complete('o1', current.pickupCode, 'admin') + await service.complete('o1', 'admin') expect(current.status).toBe('COMPLETED') expect(current.completedBy).toBe('admin') - await expect(service.complete('o1', current.pickupCode, 'admin')).rejects.toThrow('不可核销') + await expect(service.complete('o1', 'admin')).rejects.toThrow('仅已付款') }) it('scopes user lists and hides inactive products publicly', async () => { await service.orders(new ShopQueryDto(), 'u1') diff --git a/packages/server/src/shop/dto/shop.dto.ts b/packages/server/src/shop/dto/shop.dto.ts index 880666c..6210e80 100644 --- a/packages/server/src/shop/dto/shop.dto.ts +++ b/packages/server/src/shop/dto/shop.dto.ts @@ -62,6 +62,3 @@ export class ShopQueryDto { @IsOptional() @IsString() @MaxLength(80) search?: string @IsOptional() @IsString() @MaxLength(50) productId?: string } -export class PickupDto { - @IsString() @Matches(/^[A-F0-9]{12}$/) pickupCode!: string -} diff --git a/packages/server/src/shop/shop.controller.ts b/packages/server/src/shop/shop.controller.ts index b9e8907..457319f 100644 --- a/packages/server/src/shop/shop.controller.ts +++ b/packages/server/src/shop/shop.controller.ts @@ -17,7 +17,7 @@ import { RolesGuard } from '../auth/roles.guard' import { Roles } from '../auth/roles.decorator' import { CurrentUser } from '../common/decorators/current-user.decorator' import { ShopService } from './shop.service' -import { BuyProductDto, PickupDto, ProductDto, ShopQueryDto, StockDto } from './dto/shop.dto' +import { BuyProductDto, ProductDto, ShopQueryDto, StockDto } from './dto/shop.dto' @Controller('shop') export class ShopController { @@ -101,9 +101,8 @@ export class ShopAdminController { } @Post('orders/:id/complete') complete( @Param('id') id: string, - @Body() dto: PickupDto, @CurrentUser('sub') userId: string, ) { - return this.shop.complete(id, dto.pickupCode, userId) + return this.shop.complete(id, userId) } } diff --git a/packages/server/src/shop/shop.service.ts b/packages/server/src/shop/shop.service.ts index 8102e5f..b4b855d 100644 --- a/packages/server/src/shop/shop.service.ts +++ b/packages/server/src/shop/shop.service.ts @@ -7,7 +7,7 @@ import { } from '@nestjs/common' import { Cron } from '@nestjs/schedule' import { Prisma, ProductOrder } from '@prisma/client' -import { randomBytes, randomUUID } from 'crypto' +import { randomUUID } from 'crypto' import { PrismaService } from '../prisma/prisma.service' import { WechatPayService } from '../payment/wechat-pay.service' import { BuyProductDto, ProductDto, ShopQueryDto, StockDto } from './dto/shop.dto' @@ -139,7 +139,6 @@ export class ShopService { coverUrl: product.coverUrl, unitPrice: product.price, amount: product.price * dto.quantity, - pickupCode: randomBytes(6).toString('hex').toUpperCase(), expiresAt: new Date(Date.now() + 15 * 60 * 1000), }, }) @@ -273,12 +272,12 @@ export class ShopService { return { items, total, page: query.page, limit: query.limit } } - async complete(id: string, pickupCode: string, operatorId: string) { + async complete(id: string, operatorId: string) { const result = await this.prisma.productOrder.updateMany({ - where: { id, pickupCode, status: 'PAID' }, + where: { id, status: 'PAID' }, data: { status: 'COMPLETED', completedAt: new Date(), completedBy: operatorId }, }) - if (!result.count) throw new BadRequestException('提货码不正确,或订单不可核销') + if (!result.count) throw new BadRequestException('仅已付款订单可以发货完成') return this.prisma.productOrder.findUniqueOrThrow({ where: { id } }) } diff --git a/packages/shared/src/types/shop.ts b/packages/shared/src/types/shop.ts index 54d0878..df5d594 100644 --- a/packages/shared/src/types/shop.ts +++ b/packages/shared/src/types/shop.ts @@ -24,7 +24,6 @@ export interface ShopOrder { contactName: string contactPhone: string status: ShopOrderStatus - pickupCode: string expiresAt: string createdAt: string paidAt: string | null