feat: 支持商品管理
This commit is contained in:
92
docs/studio-shop.md
Normal file
92
docs/studio-shop.md
Normal file
@@ -0,0 +1,92 @@
|
|||||||
|
# 馆内好物:架构、使用与交付
|
||||||
|
|
||||||
|
## 产品范围
|
||||||
|
|
||||||
|
本次实现轻量的「单商品立即购买 + 微信支付 + 到馆自提」。首页进入好物列表和详情,个人中心查看商品订单;管理中心新增「商品、库存与销售订单」。
|
||||||
|
|
||||||
|
- 管理员维护标题、HTTPS 封面(复用 COS 上传)、说明、价格、排序、上下架状态。
|
||||||
|
- 各尺码、颜色按独立商品管理,在标题中标明规格;不引入购物车、物流、优惠券或复杂 SKU 系统。
|
||||||
|
- 创建商品默认库存为 0;保存后通过库存调整补货。调整数量为有符号整数,必须填写原因。
|
||||||
|
- 顾客填写取货人、手机号及数量,确认购买后微信付款。价格变化时先阻止下单,要求确认最新价格。
|
||||||
|
- 付款成功后显示提货码。管理员核对商品数量和取货人,输入顾客的 12 位提货码并确认交付。
|
||||||
|
- 管理端支持商品搜索、订单状态筛选、商品名 / 订单号 / 手机号搜索、分页、累计成交额、销量、待取货数、低库存提示。
|
||||||
|
- 售后通过详情页「联系场馆」沟通;本版不含线上退款流程。不能把已付款订单当作未付款订单取消。若后续引入商户平台退款,需配套退款对账及库存回补逻辑,不能仅修改订单状态。
|
||||||
|
|
||||||
|
## 现有架构与接入方式
|
||||||
|
|
||||||
|
仓库为 pnpm workspace:`app`(Vue 3、Pinia、uni-app)、`server`(NestJS、Prisma/MySQL)、`shared`(共享类型)。现有会员卡订单与开卡、续卡、邀请赠课、身体画像关联,因此实物商品使用独立模型和模块,不进入会员卡发放链路。
|
||||||
|
|
||||||
|
- 服务端:`src/shop/`,经 `AppModule` 注册。复用 `PrismaService`、JWT、`RolesGuard`、微信支付签名/解密请求能力、现有调度框架。
|
||||||
|
- 数据:新增 `Product`、`ProductOrder`、`ProductStockChange`;订单关联现有 `User`。
|
||||||
|
- 前端:`pages/shop` 独立分包,首页 `ProductShop` 展示精选商品;用户及管理员复用 `ShopOrders` 组件;商品管理仍在 admin 分包。
|
||||||
|
- 共享类型:`types/shop.ts`。所有金额按整数分传输,界面按元展示。
|
||||||
|
- 视觉:延续首页奶油白、暖棕、鼠尾草绿,用大封面、留白、衬线标题和清晰的价格层级呈现馆内精选。
|
||||||
|
- 技能:使用 frontend-design 指导界面;服务端遵循仓库既有 NestJS/Prisma、DTO 校验、权限、事务和 Jest 约定。当前技能目录没有专门的 NestJS 后端技能,没有引入无关鉴权框架。
|
||||||
|
|
||||||
|
## 库存和支付规则
|
||||||
|
|
||||||
|
`Product.stock` 表示**当前可售库存**,已经排除待付款订单预占量。`sold` 为已付款订单的累计件数。
|
||||||
|
|
||||||
|
1. 下单以用户行锁串行限制每人最多 5 笔待付款订单,以 `(userId, requestId)` 保证幂等。
|
||||||
|
2. 事务内读取商品,验证上架及顾客看到的单价,用条件更新 `stock >= quantity`、商品 `version` 原子预占库存。订单保存当时的标题、封面、单价和联系人快照。
|
||||||
|
3. 每笔最多 99 件,订单有效期 15 分钟。统一下单使用同一个商户订单号和明确的微信 `time_expire`。
|
||||||
|
4. 客户端支付成功不直接更新订单。服务端查询微信,核对商户、应用、订单号、金额、币种、openid 和微信交易号,才能从 PENDING 更新为 PAID。
|
||||||
|
5. 商品回调独立使用 `/api/shop/wx-notify`,只接受 AES-GCM 解密后的资源,再查单确认,不依赖旧会员卡回调的验签占位实现。成功返回 HTTP 204,绕过统一 API 包装。
|
||||||
|
6. 定时任务每 30 秒按最近对账时间分批扫描待付款订单,支持漏回调恢复;用户也可刷新支付状态。失败会保留库存并重试。
|
||||||
|
7. 取消订单先查单、关单,关单成功后才原子释放库存;若先查到支付成功则转为待取货。关单失败绝不提前释放。
|
||||||
|
8. 微信尚无订单时,可能存在正在途中的预支付请求;过期后再等待 60 秒才释放,避免“取消释放后又支付”。因此用户从未成功调起支付时,取消可能提示等待,最晚由超时任务回收(微信服务异常期间会继续保留并重试)。
|
||||||
|
9. 状态更新带条件,重复回调、重复取消或并发扫描不会重复计算销量、重复返还库存。核销仅允许 PAID + 正确提货码,并记录操作人/时间。
|
||||||
|
10. 管理员保存商品和库存均校验版本;发生竞争时提示刷新。库存调整记录最近 50 条人工调整原因及操作人;系统预占/释放由订单本身追溯。
|
||||||
|
|
||||||
|
状态:`PENDING → PAID → COMPLETED`;`PENDING → CANCELLED`。商品可下架但不删除,保证历史订单有效。
|
||||||
|
|
||||||
|
## 接口
|
||||||
|
|
||||||
|
公开:`GET /shop/products`、`GET /shop/products/:id`。
|
||||||
|
|
||||||
|
登录用户:`POST /shop/orders`、`GET /shop/orders`、`GET /shop/orders/:id`,以及 `POST /shop/orders/:id/pay|sync|cancel`。所有读写均按 JWT 用户限定订单归属。
|
||||||
|
|
||||||
|
管理员:`/admin/shop/summary`、`/admin/shop/products`(GET/POST)、`/admin/shop/products/:id`(GET/PUT)、`/admin/shop/products/:id/stock`(GET/POST)、`/admin/shop/orders`(GET)、`/admin/shop/orders/:id/complete`(POST)。控制器统一要求 ADMIN。
|
||||||
|
|
||||||
|
分页默认 20,最大 50;金额、数量、手机号、封面 URL、状态筛选及分页都使用 DTO 校验。客户端不能指定订单总价或购买用户。
|
||||||
|
|
||||||
|
## 上线顺序
|
||||||
|
|
||||||
|
2026-09-16 已按用户授权完成生产数据库迁移和服务端部署。以下为后续发布参考流程;本次执行结果见文末。
|
||||||
|
|
||||||
|
1. 备份数据库,先在测试数据库验证迁移 `20260916090000_studio_shop`。此迁移只新增 3 张表及关系索引,不改写已有会员数据。
|
||||||
|
2. 在 server 包运行 `pnpm exec prisma migrate deploy` 和 `pnpm prisma:generate`。
|
||||||
|
3. 根目录运行 `pnpm build:shared`、`pnpm build:server`,部署并重启后端。
|
||||||
|
4. 沿用微信支付配置和 COS 配置,无新增必填环境变量。确保 `API_BASE_URL` 指向服务域名、外网能访问 `/api/shop/wx-notify`,调度进程正常运行。
|
||||||
|
5. 构建发布小程序 `pnpm build:app`;新页面仍调用既有线上 API 域名,必须先上线后端。
|
||||||
|
6. 管理员创建真实商品、上传封面、补货、上架。没有硬编码测试商品写入数据库。
|
||||||
|
7. 在微信真机执行一笔小额购买:取消支付后续付、查看服务端支付确认、提货码核销、超时关单及库存回补。退款不在本版本内。
|
||||||
|
|
||||||
|
## 验证与限制
|
||||||
|
|
||||||
|
- 共享包、服务端构建;uni-app 类型检查;微信小程序构建;Prisma schema validate;Git diff 空白检查。
|
||||||
|
- Jest 覆盖价格变更、越权归属、负数/小数/非法输入、请求幂等、最后库存竞争、支付金额及付款人核对、重复回调、关单失败、过期释放、版本冲突、核销状态和微信 GET 签名规范。
|
||||||
|
- 全量 Jest 覆盖率运行:26 个套件、418 项测试通过(新增 60 项测试);并发测试基于条件更新行为模拟,不能替代真实 MySQL 多连接并发验证。
|
||||||
|
- 用本地独立测试数据在 390px 手机尺寸 H5 预览检查列表、详情、管理汇总、编辑页面。测试图片为标注 PREVIEW 的占位插画,未上传 COS、未接触线上商品。
|
||||||
|
- 已完成生产数据库迁移、服务端部署和线上接口冒烟验证。尚未执行真实微信扣款、COS 实际上传或微信真机验收;小程序新版仍需发布。
|
||||||
|
- 仓库 `pnpm lint` 目前失败于 `eslint: command not found`;package.json 没有安装 ESLint,仓库也没有提供 ESLint 配置。此项不代表 lint 已通过。
|
||||||
|
- 构建有既有 Browserslist 数据过期、Sass legacy API 警告;商城共享样式已改为纯 CSS,通过 Vue `<style scoped src="…/shop.css">` 引入,避免 Sass `@import` 弃用警告及 uni-app 全局样式注入对 `@use` 顺序的影响。
|
||||||
|
|
||||||
|
微信协议参考:[官方 JSAPI 查单、关单接口](https://github.com/wechatpay-apiv3/wechatpay-go/blob/main/docs/payments/jsapi/JsapiApi.md)、[官方小程序下单及有效期说明](https://pay.wechatpay.cn/doc/v3/merchant/4012791897)。
|
||||||
|
|
||||||
|
## 生产部署记录(2026-09-16)
|
||||||
|
|
||||||
|
- 服务目录:`/usr/local/web/mp-pilates-server`;共享包:`/usr/local/web/mp-pilates-shared`。
|
||||||
|
- 使用服务器现有 Node 22.16.0、PM2 和 Prisma 5.22.0。仓库旧部署脚本写死的 Node 22.17.1 路径不适用于当前服务器,本次未使用该脚本。
|
||||||
|
- 迁移:`20260916090000_studio_shop` 成功;全部 10 个迁移完成。新三表可通过 Prisma Client 正常访问,初始均为 0 条。
|
||||||
|
- 发布包 SHA-256:`7eeea42587cf2e188e337619a9a4ea0b88bbe97fe3857e733e39b4e65fe3f1fb`。
|
||||||
|
- 备份目录:`/usr/local/web/releases/mp-pilates-shop-20260916/`(服务器本机受限访问)。
|
||||||
|
- `database-before.sql.gz`:迁移前完整数据库逻辑备份,已检查 gzip 完整性。
|
||||||
|
- `previous-build.tgz`:旧服务、共享包、Prisma schema/迁移和生成的 Prisma Client。
|
||||||
|
- `live-dist-before` / `live-shared-before`:切换前运行产物。
|
||||||
|
- `release.tgz` / `stage`:本次发布包和暂存文件。
|
||||||
|
- 保留线上环境配置、支付证书和已有依赖。PM2 已重启并保存进程配置,健康检查及商品列表成功。
|
||||||
|
- 公网验证:健康、商品列表、场馆信息均 200;非法分页 400;未登录商品订单/管理接口 401。
|
||||||
|
- 服务器本机验证:管理员商品/订单/汇总接口 200;普通会员管理接口 403;用户商品订单 200;旧会员卡列表 200;无效支付回调 400。权限验证使用 30 秒有效、仅用于只读冒烟检查的测试身份,不操作真实会员账号。
|
||||||
|
- 未创建演示商品、测试订单或发起真实扣款。小程序新版发布后,管理员即可从新入口维护真实商品。
|
||||||
|
- 回滚以恢复旧运行产物和对应生成 Client、重启 PM2 为主;新表为增量结构,应保留,避免删除上线后产生的数据。数据库全量恢复仅用于灾难恢复,不应在正常版本回滚时直接覆盖。
|
||||||
150
packages/app/src/components/ProductShop.vue
Normal file
150
packages/app/src/components/ProductShop.vue
Normal file
@@ -0,0 +1,150 @@
|
|||||||
|
<template>
|
||||||
|
<view class="shop-section">
|
||||||
|
<view class="heading" @tap="openShop">
|
||||||
|
<view>
|
||||||
|
<text class="kicker">THE STUDIO EDIT</text>
|
||||||
|
<text class="title">练习之外,好物相伴</text>
|
||||||
|
</view>
|
||||||
|
<text class="more">全部好物 ↗</text>
|
||||||
|
</view>
|
||||||
|
<text class="intro">馆内精选 · 在线购买 · 到馆自提</text>
|
||||||
|
<view v-if="loading" class="state">正在准备好物…</view>
|
||||||
|
<view v-else-if="failed" class="state" @tap="load">好物暂未加载,点击重试 ↻</view>
|
||||||
|
<view v-else-if="!products.length" class="state">好物正在筹备,期待下次相遇</view>
|
||||||
|
<scroll-view v-else scroll-x class="rail">
|
||||||
|
<view class="rail-inner">
|
||||||
|
<view v-for="p in products" :key="p.id" class="product" @tap="detail(p.id)">
|
||||||
|
<view class="cover">
|
||||||
|
<image :src="p.coverUrl" mode="aspectFill" lazy-load />
|
||||||
|
<text class="tag">{{ p.stock ? '到馆自提' : '暂时售罄' }}</text>
|
||||||
|
</view>
|
||||||
|
<text class="name">{{ p.title }}</text>
|
||||||
|
<view class="price-line">
|
||||||
|
<text class="price">¥{{ shopMoney(p.price) }}</text>
|
||||||
|
<text class="arrow">↗</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</scroll-view>
|
||||||
|
</view>
|
||||||
|
</template>
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref } from 'vue'
|
||||||
|
import type { ShopProduct, ShopPage } from '@mp-pilates/shared'
|
||||||
|
import { get } from '../utils/request'
|
||||||
|
import { shopMoney } from '../utils/shop'
|
||||||
|
const products = ref<ShopProduct[]>([]),
|
||||||
|
loading = ref(true),
|
||||||
|
failed = ref(false)
|
||||||
|
async function load() {
|
||||||
|
loading.value = true
|
||||||
|
failed.value = false
|
||||||
|
try {
|
||||||
|
products.value = (await get<ShopPage<ShopProduct>>('/shop/products', { limit: 6 })).items
|
||||||
|
} catch {
|
||||||
|
failed.value = true
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const openShop = () => uni.navigateTo({ url: '/pages/shop/index' })
|
||||||
|
const detail = (id: string) => uni.navigateTo({ url: `/pages/shop/detail?id=${id}` })
|
||||||
|
defineExpose({ load })
|
||||||
|
</script>
|
||||||
|
<style scoped lang="scss">
|
||||||
|
.shop-section {
|
||||||
|
margin: 40rpx 32rpx;
|
||||||
|
padding: 32rpx 0;
|
||||||
|
border-top: 1rpx solid #e5ded3;
|
||||||
|
}
|
||||||
|
.heading {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
.kicker {
|
||||||
|
display: block;
|
||||||
|
color: #8b9687;
|
||||||
|
letter-spacing: 4rpx;
|
||||||
|
font-size: 18rpx;
|
||||||
|
}
|
||||||
|
.title {
|
||||||
|
display: block;
|
||||||
|
font-size: 36rpx;
|
||||||
|
color: #493f35;
|
||||||
|
margin-top: 12rpx;
|
||||||
|
font-family: serif;
|
||||||
|
}
|
||||||
|
.more {
|
||||||
|
font-size: 22rpx;
|
||||||
|
color: #617d73;
|
||||||
|
}
|
||||||
|
.intro {
|
||||||
|
display: block;
|
||||||
|
font-size: 23rpx;
|
||||||
|
color: #978b80;
|
||||||
|
margin: 18rpx 0 28rpx;
|
||||||
|
}
|
||||||
|
.rail {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
.rail-inner {
|
||||||
|
display: flex;
|
||||||
|
gap: 22rpx;
|
||||||
|
}
|
||||||
|
.product {
|
||||||
|
flex-shrink: 0;
|
||||||
|
width: 270rpx;
|
||||||
|
}
|
||||||
|
.cover {
|
||||||
|
position: relative;
|
||||||
|
height: 320rpx;
|
||||||
|
background: #e9e6df;
|
||||||
|
border-radius: 16rpx;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.cover image {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
.tag {
|
||||||
|
position: absolute;
|
||||||
|
bottom: 16rpx;
|
||||||
|
left: 16rpx;
|
||||||
|
background: #fbf9f6eb;
|
||||||
|
color: #617064;
|
||||||
|
padding: 8rpx 14rpx;
|
||||||
|
border-radius: 8rpx;
|
||||||
|
font-size: 19rpx;
|
||||||
|
}
|
||||||
|
.name {
|
||||||
|
display: block;
|
||||||
|
overflow: hidden;
|
||||||
|
white-space: nowrap;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
color: #4a4035;
|
||||||
|
font-size: 27rpx;
|
||||||
|
margin-top: 18rpx;
|
||||||
|
}
|
||||||
|
.price-line {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
margin-top: 10rpx;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
.price {
|
||||||
|
font-size: 29rpx;
|
||||||
|
color: #6e7e68;
|
||||||
|
}
|
||||||
|
.arrow {
|
||||||
|
color: #8b9687;
|
||||||
|
}
|
||||||
|
.state {
|
||||||
|
padding: 56rpx 24rpx;
|
||||||
|
text-align: center;
|
||||||
|
color: #978b80;
|
||||||
|
background: #f1eee7;
|
||||||
|
border-radius: 20rpx;
|
||||||
|
font-size: 25rpx;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -41,6 +41,7 @@ const emit = defineEmits<{
|
|||||||
|
|
||||||
const menuItems = computed<MenuItem[]>(() => {
|
const menuItems = computed<MenuItem[]>(() => {
|
||||||
const items: MenuItem[] = [
|
const items: MenuItem[] = [
|
||||||
|
{ key: 'shop-orders', type: 'item', title: '我的商品订单', path: '/pages/shop/orders', requireAuth: true },
|
||||||
{ key: 'progress', type: 'item', title: '我的成长档案', path: '/pages/profile/progress', requireAuth: true },
|
{ key: 'progress', type: 'item', title: '我的成长档案', path: '/pages/profile/progress', requireAuth: true },
|
||||||
{ key: 'portrait', type: 'item', title: '身体状态评估', path: '/pages/portrait/index' },
|
{ key: 'portrait', type: 'item', title: '身体状态评估', path: '/pages/portrait/index' },
|
||||||
{ key: 'plan', type: 'item', title: '我的改善计划', path: '/pages/portrait/plan', requireAuth: true },
|
{ key: 'plan', type: 'item', title: '我的改善计划', path: '/pages/portrait/plan', requireAuth: true },
|
||||||
|
|||||||
238
packages/app/src/components/ShopOrders.vue
Normal file
238
packages/app/src/components/ShopOrders.vue
Normal file
@@ -0,0 +1,238 @@
|
|||||||
|
<template>
|
||||||
|
<view>
|
||||||
|
<input
|
||||||
|
v-if="admin"
|
||||||
|
v-model="search"
|
||||||
|
class="input search"
|
||||||
|
placeholder="搜索商品、订单号或手机号"
|
||||||
|
confirm-type="search"
|
||||||
|
@confirm="load(true)"
|
||||||
|
/>
|
||||||
|
<view class="tabs">
|
||||||
|
<text
|
||||||
|
v-for="tab in tabs"
|
||||||
|
:key="tab.value"
|
||||||
|
class="tab"
|
||||||
|
:class="{ active: status === tab.value }"
|
||||||
|
@tap="filter(tab.value)"
|
||||||
|
>
|
||||||
|
{{ tab.label }}
|
||||||
|
</text>
|
||||||
|
</view>
|
||||||
|
<view class="row">
|
||||||
|
<text class="muted">共 {{ total }} 笔订单</text>
|
||||||
|
<text class="badge" @tap="load(true)">刷新 ↻</text>
|
||||||
|
</view>
|
||||||
|
<view style="height: 24rpx" />
|
||||||
|
<view v-if="error" class="error" @tap="load(true)">{{ error }} · 点击重试</view>
|
||||||
|
<view v-if="!items.length && !error" class="state">
|
||||||
|
{{ loading ? '加载中…' : '这里还没有订单' }}
|
||||||
|
</view>
|
||||||
|
<view v-for="order in items" :key="order.id" class="panel">
|
||||||
|
<view class="row">
|
||||||
|
<text class="muted">{{ date(order.createdAt) }}</text>
|
||||||
|
<text class="badge">{{ shopStatus[order.status] }}</text>
|
||||||
|
</view>
|
||||||
|
<view class="divider" />
|
||||||
|
<view class="row">
|
||||||
|
<image class="thumb" :src="order.coverUrl" mode="aspectFill" />
|
||||||
|
<view style="flex: 1">
|
||||||
|
<text class="item-title">{{ order.title }}</text>
|
||||||
|
<text class="muted">¥{{ shopMoney(order.unitPrice) }} × {{ order.quantity }}</text>
|
||||||
|
<text class="price">¥{{ shopMoney(order.amount) }}</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
<view class="divider" />
|
||||||
|
<text class="muted">{{ order.contactName }} · {{ order.contactPhone }}</text>
|
||||||
|
<text class="order-no" @tap="copy(order.orderNo)">订单号 {{ order.orderNo }} ⧉</text>
|
||||||
|
<template v-if="order.status === 'PAID'">
|
||||||
|
<view v-if="!admin" class="notice">
|
||||||
|
到馆出示提货码
|
||||||
|
<text class="code" @tap="copy(order.pickupCode)">{{ order.pickupCode }}</text>
|
||||||
|
请在领取商品时向工作人员出示。
|
||||||
|
</view>
|
||||||
|
<view v-else class="field">
|
||||||
|
<input
|
||||||
|
v-model="codes[order.id]"
|
||||||
|
class="input"
|
||||||
|
maxlength="12"
|
||||||
|
placeholder="输入顾客的 12 位提货码"
|
||||||
|
/>
|
||||||
|
</view>
|
||||||
|
</template>
|
||||||
|
<text v-if="order.status === 'PENDING'" class="muted">
|
||||||
|
付款截止 {{ date(order.expiresAt) }},超时后自动取消。
|
||||||
|
</text>
|
||||||
|
<view class="actions">
|
||||||
|
<template v-if="order.status === 'PENDING' && !admin">
|
||||||
|
<button class="secondary" :disabled="!!busy" @tap="cancel(order)">取消订单</button>
|
||||||
|
<button class="secondary" :disabled="!!busy" @tap="sync(order)">刷新支付状态</button>
|
||||||
|
<button class="primary" :disabled="!!busy || expired(order)" @tap="pay(order)">
|
||||||
|
继续付款
|
||||||
|
</button>
|
||||||
|
</template>
|
||||||
|
<button
|
||||||
|
v-if="admin && order.status === 'PAID'"
|
||||||
|
class="primary"
|
||||||
|
:disabled="!!busy"
|
||||||
|
@tap="complete(order)"
|
||||||
|
>
|
||||||
|
确认已交付商品
|
||||||
|
</button>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
<button
|
||||||
|
v-if="items.length < total"
|
||||||
|
class="secondary load-more"
|
||||||
|
:disabled="loading"
|
||||||
|
@tap="load(false)"
|
||||||
|
>
|
||||||
|
加载更多
|
||||||
|
</button>
|
||||||
|
</view>
|
||||||
|
</template>
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref } from 'vue'
|
||||||
|
import type { ShopOrder, ShopPage } from '@mp-pilates/shared'
|
||||||
|
import { get, post } from '../utils/request'
|
||||||
|
import { shopMoney, shopStatus, shopError, payShopOrder } from '../utils/shop'
|
||||||
|
const props = defineProps<{ admin?: boolean }>(),
|
||||||
|
emit = defineEmits<{ (e: 'changed'): void }>()
|
||||||
|
const items = ref<ShopOrder[]>([]),
|
||||||
|
status = ref(''),
|
||||||
|
search = ref(''),
|
||||||
|
page = ref(0),
|
||||||
|
total = ref(0),
|
||||||
|
loading = ref(false),
|
||||||
|
error = ref(''),
|
||||||
|
busy = ref(''),
|
||||||
|
codes = ref<Record<string, string>>({})
|
||||||
|
const tabs = [
|
||||||
|
{ value: '', label: '全部' },
|
||||||
|
...Object.entries(shopStatus).map(([value, label]) => ({ value, label })),
|
||||||
|
]
|
||||||
|
const date = (value: string) => {
|
||||||
|
const date = new Date(value)
|
||||||
|
const pad = (n: number) => String(n).padStart(2, '0')
|
||||||
|
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${pad(date.getHours())}:${pad(date.getMinutes())}`
|
||||||
|
}
|
||||||
|
const expired = (o: ShopOrder) => new Date(o.expiresAt).getTime() <= Date.now()
|
||||||
|
const copy = (data: string) => uni.setClipboardData({ data })
|
||||||
|
async function load(reset = true) {
|
||||||
|
if (loading.value) return
|
||||||
|
loading.value = true
|
||||||
|
error.value = ''
|
||||||
|
try {
|
||||||
|
const data = await get<ShopPage<ShopOrder>>(
|
||||||
|
props.admin ? '/admin/shop/orders' : '/shop/orders',
|
||||||
|
{
|
||||||
|
page: reset ? 1 : page.value + 1,
|
||||||
|
...(status.value ? { status: status.value } : {}),
|
||||||
|
search: search.value,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
items.value = reset ? data.items : [...items.value, ...data.items]
|
||||||
|
total.value = data.total
|
||||||
|
page.value = data.page
|
||||||
|
} catch (e) {
|
||||||
|
error.value = e instanceof Error ? e.message : '加载失败'
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function filter(value: string) {
|
||||||
|
if (loading.value) return
|
||||||
|
status.value = value
|
||||||
|
load(true)
|
||||||
|
}
|
||||||
|
async function sync(order: ShopOrder) {
|
||||||
|
if (busy.value) return
|
||||||
|
busy.value = order.id
|
||||||
|
try {
|
||||||
|
const updated = await post<ShopOrder>(`/shop/orders/${order.id}/sync`)
|
||||||
|
items.value = items.value.map((o) => (o.id === updated.id ? updated : o))
|
||||||
|
uni.showToast({
|
||||||
|
title:
|
||||||
|
updated.status === 'PENDING' ? '仍在等待付款确认,请稍后刷新' : shopStatus[updated.status],
|
||||||
|
icon: 'none',
|
||||||
|
})
|
||||||
|
} catch (e) {
|
||||||
|
shopError(e)
|
||||||
|
} finally {
|
||||||
|
busy.value = ''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
async function syncId(id: string) {
|
||||||
|
try {
|
||||||
|
await post(`/shop/orders/${id}/sync`)
|
||||||
|
} catch (e) {
|
||||||
|
shopError(e)
|
||||||
|
}
|
||||||
|
await load(true)
|
||||||
|
}
|
||||||
|
async function pay(order: ShopOrder) {
|
||||||
|
if (busy.value) return
|
||||||
|
busy.value = order.id
|
||||||
|
try {
|
||||||
|
await payShopOrder(order.id)
|
||||||
|
} catch (e) {
|
||||||
|
shopError(e)
|
||||||
|
} finally {
|
||||||
|
busy.value = ''
|
||||||
|
await sync(order)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
async function cancel(order: ShopOrder) {
|
||||||
|
const answer = await uni.showModal({
|
||||||
|
title: '取消订单',
|
||||||
|
content: '确认取消这笔未付款订单?库存会在微信关单确认后释放。',
|
||||||
|
})
|
||||||
|
if (!answer.confirm || busy.value) return
|
||||||
|
busy.value = order.id
|
||||||
|
try {
|
||||||
|
await post(`/shop/orders/${order.id}/cancel`)
|
||||||
|
await load(true)
|
||||||
|
} catch (e) {
|
||||||
|
shopError(e)
|
||||||
|
} finally {
|
||||||
|
busy.value = ''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
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}。核销后订单将完成。`,
|
||||||
|
})
|
||||||
|
if (!answer.confirm || busy.value) return
|
||||||
|
busy.value = order.id
|
||||||
|
try {
|
||||||
|
await post(`/admin/shop/orders/${order.id}/complete`, { pickupCode: code })
|
||||||
|
await load(true)
|
||||||
|
emit('changed')
|
||||||
|
uni.showToast({ title: '提货完成' })
|
||||||
|
} catch (e) {
|
||||||
|
shopError(e)
|
||||||
|
} finally {
|
||||||
|
busy.value = ''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
defineExpose({ load, syncId })
|
||||||
|
</script>
|
||||||
|
<style scoped src="../pages/shop/shop.css"></style>
|
||||||
|
<style scoped lang="scss">
|
||||||
|
.price {
|
||||||
|
font-size: 31rpx;
|
||||||
|
}
|
||||||
|
.order-no {
|
||||||
|
display: block;
|
||||||
|
margin-top: 14rpx;
|
||||||
|
}
|
||||||
|
.actions:empty {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -206,6 +206,18 @@
|
|||||||
"style": {
|
"style": {
|
||||||
"navigationStyle": "custom"
|
"navigationStyle": "custom"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "products",
|
||||||
|
"style": {
|
||||||
|
"navigationStyle": "custom"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "product-edit",
|
||||||
|
"style": {
|
||||||
|
"navigationStyle": "custom"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
@@ -249,6 +261,29 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"root": "pages/shop",
|
||||||
|
"pages": [
|
||||||
|
{
|
||||||
|
"path": "index",
|
||||||
|
"style": {
|
||||||
|
"navigationStyle": "custom"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "detail",
|
||||||
|
"style": {
|
||||||
|
"navigationStyle": "custom"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "orders",
|
||||||
|
"style": {
|
||||||
|
"navigationStyle": "custom"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"globalStyle": {
|
"globalStyle": {
|
||||||
|
|||||||
@@ -62,6 +62,9 @@
|
|||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
|
|
||||||
|
<view class="section-header"><text class="section-title">馆内好物</text></view>
|
||||||
|
<view class="list"><view class="list-item" @tap="navigate('/pages/admin/products')"><text class="item-title">商品、库存与销售订单</text><text class="arrow-text">›</text></view></view>
|
||||||
|
|
||||||
<!-- Section: 系统设置 -->
|
<!-- Section: 系统设置 -->
|
||||||
<view class="section-header">
|
<view class="section-header">
|
||||||
<text class="section-title">系统设置</text>
|
<text class="section-title">系统设置</text>
|
||||||
|
|||||||
299
packages/app/src/pages/admin/product-edit.vue
Normal file
299
packages/app/src/pages/admin/product-edit.vue
Normal file
@@ -0,0 +1,299 @@
|
|||||||
|
<template>
|
||||||
|
<view class="shop-page" :style="{ paddingTop: navBarHeight }">
|
||||||
|
<CustomNavBar :title="id ? '编辑商品' : '添加商品'" show-back />
|
||||||
|
<view v-if="user.isAdmin" class="content">
|
||||||
|
<text class="eyebrow">CURATE YOUR STUDIO</text>
|
||||||
|
<text class="headline">{{ id ? '维护一件好物' : '上新一件好物' }}</text>
|
||||||
|
<text class="muted">每个尺码 / 颜色可单独创建商品,标题中注明规格。</text>
|
||||||
|
<view v-if="loadError" class="state" @tap="load">{{ loadError }} · 点击重试</view>
|
||||||
|
<template v-else-if="ready">
|
||||||
|
<view class="cover-picker" @tap="upload">
|
||||||
|
<image v-if="form.coverUrl" :src="form.coverUrl" mode="aspectFill" />
|
||||||
|
<view class="cover-hint">
|
||||||
|
{{ uploading ? '正在上传…' : form.coverUrl ? '点击更换封面' : '+ 上传商品封面' }}
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
<view class="panel">
|
||||||
|
<view class="field">
|
||||||
|
<text class="label">商品标题(含规格)</text>
|
||||||
|
<input
|
||||||
|
v-model="form.title"
|
||||||
|
class="input"
|
||||||
|
maxlength="80"
|
||||||
|
placeholder="例如:防滑普拉提袜 · 奶白 / 均码"
|
||||||
|
/>
|
||||||
|
</view>
|
||||||
|
<view class="field">
|
||||||
|
<text class="label">价格(元)</text>
|
||||||
|
<input v-model="price" class="input" type="digit" placeholder="0.00" />
|
||||||
|
</view>
|
||||||
|
<view class="field">
|
||||||
|
<text class="label">商品说明</text>
|
||||||
|
<textarea
|
||||||
|
v-model="form.description"
|
||||||
|
class="input textarea"
|
||||||
|
maxlength="5000"
|
||||||
|
placeholder="材质、尺码、适用场景、清洗方式与售后说明…"
|
||||||
|
/>
|
||||||
|
</view>
|
||||||
|
<view class="field">
|
||||||
|
<text class="label">展示顺序(数字越小越靠前)</text>
|
||||||
|
<input v-model="sort" class="input" type="number" />
|
||||||
|
</view>
|
||||||
|
<view class="row">
|
||||||
|
<view>
|
||||||
|
<text class="label">上架展示</text>
|
||||||
|
<text class="muted">下架不影响已有订单的提货</text>
|
||||||
|
</view>
|
||||||
|
<switch :checked="form.isActive" color="#647c6b" @change="toggleActive" />
|
||||||
|
</view>
|
||||||
|
<button class="primary save" :disabled="busy || uploading" :loading="busy" @tap="save">
|
||||||
|
保存商品信息
|
||||||
|
</button>
|
||||||
|
</view>
|
||||||
|
<view v-if="id" class="panel">
|
||||||
|
<text class="item-title">库存维护</text>
|
||||||
|
<view class="notice">
|
||||||
|
当前可售 {{ stock }} 件 · 已售 {{ sold }} 件
|
||||||
|
<text></text>
|
||||||
|
待付款订单已预占库存。这里调整的是可售库存。
|
||||||
|
</view>
|
||||||
|
<view class="field">
|
||||||
|
<text class="label">调整数量(补货填正数,盘亏填负数)</text>
|
||||||
|
<input
|
||||||
|
v-model="delta"
|
||||||
|
class="input"
|
||||||
|
type="text"
|
||||||
|
maxlength="8"
|
||||||
|
placeholder="例如 20 或 -2"
|
||||||
|
/>
|
||||||
|
</view>
|
||||||
|
<view class="field">
|
||||||
|
<text class="label">调整原因</text>
|
||||||
|
<input
|
||||||
|
v-model="reason"
|
||||||
|
class="input"
|
||||||
|
maxlength="200"
|
||||||
|
placeholder="例如:本周到货 / 盘点差异"
|
||||||
|
/>
|
||||||
|
</view>
|
||||||
|
<button class="secondary" :disabled="busy" @tap="adjust">确认调整库存</button>
|
||||||
|
<button class="secondary refresh" :disabled="busy" @tap="reload">刷新商品与库存</button>
|
||||||
|
<view v-if="history.length" class="divider" />
|
||||||
|
<text v-if="history.length" class="label">最近 50 次人工调整</text>
|
||||||
|
<view v-for="h in history" :key="h.id" class="history row">
|
||||||
|
<view>
|
||||||
|
<text class="label">{{ h.reason }}</text>
|
||||||
|
<text class="muted">{{ new Date(h.createdAt).toLocaleString() }}</text>
|
||||||
|
</view>
|
||||||
|
<text class="price">{{ h.delta > 0 ? '+' : '' }}{{ h.delta }}</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
<view v-else class="notice">先保存商品,再补充库存。新商品默认库存为 0。</view>
|
||||||
|
</template>
|
||||||
|
<view v-else class="state">加载中…</view>
|
||||||
|
</view>
|
||||||
|
<view v-else class="state">仅管理员可编辑商品</view>
|
||||||
|
</view>
|
||||||
|
</template>
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { reactive, ref } from 'vue'
|
||||||
|
import { onLoad } from '@dcloudio/uni-app'
|
||||||
|
import type { ShopProduct, ShopStockChange } from '@mp-pilates/shared'
|
||||||
|
import CustomNavBar from '../../components/CustomNavBar.vue'
|
||||||
|
import { getSystemLayout } from '../../utils/system'
|
||||||
|
import { useUserStore } from '../../stores/user'
|
||||||
|
import { useAdminStore } from './stores/admin'
|
||||||
|
import { uploadStudioAsset } from './utils/studio-upload'
|
||||||
|
import { get, post, put } from '../../utils/request'
|
||||||
|
import { shopError, shopMoney } from '../../utils/shop'
|
||||||
|
const navBarHeight = `${getSystemLayout().navBarHeight}px`,
|
||||||
|
user = useUserStore(),
|
||||||
|
admin = useAdminStore()
|
||||||
|
const id = ref(''),
|
||||||
|
price = ref(''),
|
||||||
|
sort = ref('0'),
|
||||||
|
stock = ref(0),
|
||||||
|
sold = ref(0),
|
||||||
|
version = ref(0),
|
||||||
|
delta = ref(''),
|
||||||
|
reason = ref(''),
|
||||||
|
busy = ref(false),
|
||||||
|
uploading = ref(false),
|
||||||
|
ready = ref(false),
|
||||||
|
loadError = ref(''),
|
||||||
|
history = ref<ShopStockChange[]>([])
|
||||||
|
const form = reactive({ title: '', description: '', coverUrl: '', isActive: false })
|
||||||
|
function apply(p: ShopProduct) {
|
||||||
|
Object.assign(form, {
|
||||||
|
title: p.title,
|
||||||
|
description: p.description,
|
||||||
|
coverUrl: p.coverUrl,
|
||||||
|
isActive: p.isActive,
|
||||||
|
})
|
||||||
|
price.value = shopMoney(p.price)
|
||||||
|
sort.value = String(p.sortOrder)
|
||||||
|
stock.value = p.stock
|
||||||
|
sold.value = p.sold
|
||||||
|
version.value = p.version
|
||||||
|
}
|
||||||
|
function toggleActive(event: Event) {
|
||||||
|
form.isActive = (event as unknown as { detail: { value: boolean } }).detail.value
|
||||||
|
}
|
||||||
|
async function load() {
|
||||||
|
loadError.value = ''
|
||||||
|
try {
|
||||||
|
if (id.value) {
|
||||||
|
apply(await get<ShopProduct>(`/admin/shop/products/${id.value}`))
|
||||||
|
history.value = await get<ShopStockChange[]>(`/admin/shop/products/${id.value}/stock`)
|
||||||
|
}
|
||||||
|
ready.value = true
|
||||||
|
} catch (e) {
|
||||||
|
loadError.value = e instanceof Error ? e.message : '加载失败'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
async function reload() {
|
||||||
|
const result = await uni.showModal({
|
||||||
|
title: '刷新商品',
|
||||||
|
content: '将读取最新商品与库存,尚未保存的编辑会被替换。',
|
||||||
|
})
|
||||||
|
if (result.confirm) await load()
|
||||||
|
}
|
||||||
|
async function upload() {
|
||||||
|
if (uploading.value || busy.value) return
|
||||||
|
try {
|
||||||
|
const selection = await uni.chooseImage({
|
||||||
|
count: 1,
|
||||||
|
sizeType: ['compressed'],
|
||||||
|
sourceType: ['album', 'camera'],
|
||||||
|
})
|
||||||
|
const file = selection.tempFilePaths[0]
|
||||||
|
uploading.value = true
|
||||||
|
form.coverUrl = await uploadStudioAsset({
|
||||||
|
adminStore: admin,
|
||||||
|
filePath: file,
|
||||||
|
fileName: file.split('/').pop() || 'product.jpg',
|
||||||
|
assetType: 'product-cover',
|
||||||
|
})
|
||||||
|
} catch (e) {
|
||||||
|
shopError(e)
|
||||||
|
} finally {
|
||||||
|
uploading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
async function save() {
|
||||||
|
if (busy.value || uploading.value) return
|
||||||
|
busy.value = true
|
||||||
|
try {
|
||||||
|
if (!form.title.trim() || !form.coverUrl) throw new Error('请填写标题并上传封面')
|
||||||
|
if (
|
||||||
|
!/^\d+(\.\d{1,2})?$/.test(price.value) ||
|
||||||
|
Number(price.value) <= 0 ||
|
||||||
|
Number(price.value) > 100000
|
||||||
|
)
|
||||||
|
throw new Error('价格须为 0.01~100000 元,最多两位小数')
|
||||||
|
if (!/^\d+$/.test(sort.value) || Number(sort.value) > 99999)
|
||||||
|
throw new Error('排序须为 0~99999 的整数')
|
||||||
|
const payload = {
|
||||||
|
...form,
|
||||||
|
title: form.title.trim(),
|
||||||
|
price: Math.round(Number(price.value) * 100),
|
||||||
|
sortOrder: Number(sort.value),
|
||||||
|
...(id.value ? { version: version.value } : {}),
|
||||||
|
}
|
||||||
|
const p = id.value
|
||||||
|
? await put<ShopProduct>(`/admin/shop/products/${id.value}`, payload)
|
||||||
|
: await post<ShopProduct>('/admin/shop/products', payload)
|
||||||
|
id.value = p.id
|
||||||
|
apply(p)
|
||||||
|
uni.showToast({ title: '商品已保存' })
|
||||||
|
} catch (e) {
|
||||||
|
shopError(e)
|
||||||
|
} finally {
|
||||||
|
busy.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
async function adjust() {
|
||||||
|
if (busy.value) return
|
||||||
|
if (
|
||||||
|
!/^-?\d+$/.test(delta.value) ||
|
||||||
|
!Number(delta.value) ||
|
||||||
|
Math.abs(Number(delta.value)) > 1000000 ||
|
||||||
|
!reason.value.trim()
|
||||||
|
) {
|
||||||
|
shopError(new Error('请填写有效调整数量和原因'))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const answer = await uni.showModal({
|
||||||
|
title: '确认库存调整',
|
||||||
|
content: `可售库存 ${stock.value} → ${stock.value + Number(delta.value)} 件。${reason.value}`,
|
||||||
|
})
|
||||||
|
if (!answer.confirm) return
|
||||||
|
busy.value = true
|
||||||
|
try {
|
||||||
|
const p = await post<ShopProduct>(`/admin/shop/products/${id.value}/stock`, {
|
||||||
|
delta: Number(delta.value),
|
||||||
|
version: version.value,
|
||||||
|
reason: reason.value.trim(),
|
||||||
|
})
|
||||||
|
stock.value = p.stock
|
||||||
|
version.value = p.version
|
||||||
|
delta.value = ''
|
||||||
|
reason.value = ''
|
||||||
|
history.value = await get<ShopStockChange[]>(`/admin/shop/products/${id.value}/stock`)
|
||||||
|
uni.showToast({ title: '库存已更新' })
|
||||||
|
} catch (e) {
|
||||||
|
shopError(e)
|
||||||
|
} finally {
|
||||||
|
busy.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
onLoad(async (q) => {
|
||||||
|
id.value = q?.id || ''
|
||||||
|
await user.fetchProfile()
|
||||||
|
if (user.isAdmin) load()
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
<style scoped src="../shop/shop.css"></style>
|
||||||
|
<style scoped lang="scss">
|
||||||
|
.headline {
|
||||||
|
margin-bottom: 18rpx;
|
||||||
|
}
|
||||||
|
.cover-picker {
|
||||||
|
height: 430rpx;
|
||||||
|
border-radius: 24rpx;
|
||||||
|
background: #e8e7de;
|
||||||
|
margin: 32rpx 0;
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
.cover-picker image {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
.cover-hint {
|
||||||
|
position: absolute;
|
||||||
|
bottom: 24rpx;
|
||||||
|
background: #fffc;
|
||||||
|
padding: 14rpx 28rpx;
|
||||||
|
border-radius: 12rpx;
|
||||||
|
font-size: 26rpx;
|
||||||
|
color: #697a5f;
|
||||||
|
}
|
||||||
|
.save {
|
||||||
|
margin-top: 32rpx;
|
||||||
|
}
|
||||||
|
.history {
|
||||||
|
border-top: 1rpx solid #eee8e1;
|
||||||
|
padding: 24rpx 0;
|
||||||
|
}
|
||||||
|
.history .price {
|
||||||
|
font-size: 30rpx;
|
||||||
|
}
|
||||||
|
.history .muted {
|
||||||
|
font-size: 22rpx;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
162
packages/app/src/pages/admin/products.vue
Normal file
162
packages/app/src/pages/admin/products.vue
Normal file
@@ -0,0 +1,162 @@
|
|||||||
|
<template>
|
||||||
|
<view class="shop-page" :style="{ paddingTop: navBarHeight }">
|
||||||
|
<CustomNavBar title="商品经营" show-back />
|
||||||
|
<view class="content" v-if="user.isAdmin">
|
||||||
|
<text class="eyebrow">STUDIO SHOP · MANAGEMENT</text>
|
||||||
|
<view class="row">
|
||||||
|
<text class="headline">馆内好物经营</text>
|
||||||
|
<button class="primary" @tap="edit()">+ 新商品</button>
|
||||||
|
</view>
|
||||||
|
<view v-if="summary" class="summary">
|
||||||
|
<view>
|
||||||
|
<text class="muted">累计销售额</text>
|
||||||
|
<text class="revenue">¥{{ shopMoney(summary.revenue) }}</text>
|
||||||
|
<text class="muted">{{ summary.orders }} 笔已付款 · {{ summary.quantity }} 件商品</text>
|
||||||
|
</view>
|
||||||
|
<view class="stats">
|
||||||
|
<text>{{ summary.pendingPickup }} 待取货</text>
|
||||||
|
<text>{{ summary.activeProducts }} 已上架</text>
|
||||||
|
<text>低库存 {{ summary.lowStock }} 款</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
<view class="tabs">
|
||||||
|
<text class="tab" :class="{ active: tab === 'products' }" @tap="tab = 'products'">
|
||||||
|
商品与库存
|
||||||
|
</text>
|
||||||
|
<text class="tab" :class="{ active: tab === 'orders' }" @tap="showOrders">订单与提货</text>
|
||||||
|
</view>
|
||||||
|
<view v-show="tab === 'products'">
|
||||||
|
<input
|
||||||
|
v-model="search"
|
||||||
|
class="input search"
|
||||||
|
placeholder="搜索商品标题"
|
||||||
|
confirm-type="search"
|
||||||
|
@confirm="load(true)"
|
||||||
|
/>
|
||||||
|
<text v-if="error" class="error" @tap="load(true)">{{ error }} · 点击重试</text>
|
||||||
|
<view v-if="!items.length" class="state">
|
||||||
|
{{ loading ? '加载中…' : '还没有商品,先添加一件馆内好物吧' }}
|
||||||
|
</view>
|
||||||
|
<view v-for="p in items" :key="p.id" class="panel">
|
||||||
|
<view class="row">
|
||||||
|
<image class="thumb" :src="p.coverUrl" mode="aspectFill" />
|
||||||
|
<view style="flex: 1">
|
||||||
|
<text class="item-title">{{ p.title }}</text>
|
||||||
|
<text class="price">¥{{ shopMoney(p.price) }}</text>
|
||||||
|
<text class="muted">可售 {{ p.stock }} · 已售 {{ p.sold }}</text>
|
||||||
|
</view>
|
||||||
|
<text class="badge">{{ p.isActive ? '已上架' : '已下架' }}</text>
|
||||||
|
</view>
|
||||||
|
<view class="actions">
|
||||||
|
<button class="secondary" @tap="edit(p.id)">编辑 / 调整库存</button>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
<button
|
||||||
|
v-if="items.length < total"
|
||||||
|
class="secondary"
|
||||||
|
:disabled="loading"
|
||||||
|
@tap="load(false)"
|
||||||
|
>
|
||||||
|
加载更多
|
||||||
|
</button>
|
||||||
|
</view>
|
||||||
|
<ShopOrders v-show="tab === 'orders'" ref="orders" admin @changed="loadSummary" />
|
||||||
|
</view>
|
||||||
|
<view v-else class="state">仅管理员可访问商品管理</view>
|
||||||
|
</view>
|
||||||
|
</template>
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, nextTick } from 'vue'
|
||||||
|
import { onShow } from '@dcloudio/uni-app'
|
||||||
|
import type { ShopProduct, ShopPage, ShopSummary } from '@mp-pilates/shared'
|
||||||
|
import CustomNavBar from '../../components/CustomNavBar.vue'
|
||||||
|
import ShopOrders from '../../components/ShopOrders.vue'
|
||||||
|
import { getSystemLayout } from '../../utils/system'
|
||||||
|
import { useUserStore } from '../../stores/user'
|
||||||
|
import { get } from '../../utils/request'
|
||||||
|
import { shopMoney, shopError } from '../../utils/shop'
|
||||||
|
const navBarHeight = `${getSystemLayout().navBarHeight}px`,
|
||||||
|
user = useUserStore(),
|
||||||
|
items = ref<ShopProduct[]>([]),
|
||||||
|
summary = ref<ShopSummary | null>(null),
|
||||||
|
tab = ref('products'),
|
||||||
|
search = ref(''),
|
||||||
|
page = ref(0),
|
||||||
|
total = ref(0),
|
||||||
|
loading = ref(false),
|
||||||
|
error = ref(''),
|
||||||
|
orders = ref<InstanceType<typeof ShopOrders> | null>(null)
|
||||||
|
async function loadSummary() {
|
||||||
|
try {
|
||||||
|
summary.value = await get<ShopSummary>('/admin/shop/summary')
|
||||||
|
} catch (e) {
|
||||||
|
shopError(e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
async function load(reset = true) {
|
||||||
|
if (loading.value) return
|
||||||
|
loading.value = true
|
||||||
|
error.value = ''
|
||||||
|
try {
|
||||||
|
const data = await get<ShopPage<ShopProduct>>('/admin/shop/products', {
|
||||||
|
page: reset ? 1 : page.value + 1,
|
||||||
|
search: search.value,
|
||||||
|
})
|
||||||
|
items.value = reset ? data.items : [...items.value, ...data.items]
|
||||||
|
page.value = data.page
|
||||||
|
total.value = data.total
|
||||||
|
} catch (e) {
|
||||||
|
error.value = e instanceof Error ? e.message : '加载失败'
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const edit = (id?: string) =>
|
||||||
|
uni.navigateTo({ url: `/pages/admin/product-edit${id ? '?id=' + id : ''}` })
|
||||||
|
async function showOrders() {
|
||||||
|
tab.value = 'orders'
|
||||||
|
await nextTick()
|
||||||
|
orders.value?.load(true)
|
||||||
|
}
|
||||||
|
onShow(async () => {
|
||||||
|
await user.fetchProfile()
|
||||||
|
if (user.isAdmin) {
|
||||||
|
load(true)
|
||||||
|
loadSummary()
|
||||||
|
if (tab.value === 'orders') showOrders()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
<style scoped src="../shop/shop.css"></style>
|
||||||
|
<style scoped lang="scss">
|
||||||
|
.headline {
|
||||||
|
font-size: 39rpx;
|
||||||
|
}
|
||||||
|
.summary {
|
||||||
|
margin: 32rpx 0;
|
||||||
|
padding: 32rpx;
|
||||||
|
border-radius: 24rpx;
|
||||||
|
background: #e9eee4;
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 16rpx;
|
||||||
|
}
|
||||||
|
.revenue {
|
||||||
|
display: block;
|
||||||
|
font-family: serif;
|
||||||
|
font-size: 49rpx;
|
||||||
|
color: #536c54;
|
||||||
|
margin: 16rpx 0;
|
||||||
|
}
|
||||||
|
.stats {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 24rpx;
|
||||||
|
color: #667b61;
|
||||||
|
font-size: 23rpx;
|
||||||
|
}
|
||||||
|
.price {
|
||||||
|
font-size: 30rpx;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -16,6 +16,7 @@
|
|||||||
<view :id="cardShopAnchorId">
|
<view :id="cardShopAnchorId">
|
||||||
<CardShop ref="cardShopRef" />
|
<CardShop ref="cardShopRef" />
|
||||||
</view>
|
</view>
|
||||||
|
<ProductShop ref="productShopRef" />
|
||||||
<AboutSection />
|
<AboutSection />
|
||||||
<view class="bottom-padding" />
|
<view class="bottom-padding" />
|
||||||
</view>
|
</view>
|
||||||
@@ -32,6 +33,7 @@ import ReviewSummaryCard from '../../components/ReviewSummaryCard.vue'
|
|||||||
import StudioInfo from '../../components/StudioInfo.vue'
|
import StudioInfo from '../../components/StudioInfo.vue'
|
||||||
import QuickEntry from '../../components/QuickEntry.vue'
|
import QuickEntry from '../../components/QuickEntry.vue'
|
||||||
import UpcomingBooking from '../../components/UpcomingBooking.vue'
|
import UpcomingBooking from '../../components/UpcomingBooking.vue'
|
||||||
|
import ProductShop from '../../components/ProductShop.vue'
|
||||||
import CardShop from '../../components/CardShop.vue'
|
import CardShop from '../../components/CardShop.vue'
|
||||||
import AboutSection from '../../components/AboutSection.vue'
|
import AboutSection from '../../components/AboutSection.vue'
|
||||||
|
|
||||||
@@ -62,6 +64,7 @@ onShareTimeline(() => {
|
|||||||
// ─── Layout ───────────────────────────────────────────────
|
// ─── Layout ───────────────────────────────────────────────
|
||||||
const refreshing = ref(false)
|
const refreshing = ref(false)
|
||||||
const cardShopRef = ref<InstanceType<typeof CardShop> | null>(null)
|
const cardShopRef = ref<InstanceType<typeof CardShop> | null>(null)
|
||||||
|
const productShopRef = ref<InstanceType<typeof ProductShop> | null>(null)
|
||||||
const cardShopAnchorId = 'card-shop-anchor'
|
const cardShopAnchorId = 'card-shop-anchor'
|
||||||
const scrollTarget = ref('')
|
const scrollTarget = ref('')
|
||||||
const pageHeight = ref(`${uni.getWindowInfo().windowHeight}px`)
|
const pageHeight = ref(`${uni.getWindowInfo().windowHeight}px`)
|
||||||
@@ -105,6 +108,7 @@ async function refreshData() {
|
|||||||
// Also refresh card shop
|
// Also refresh card shop
|
||||||
await Promise.allSettled([
|
await Promise.allSettled([
|
||||||
cardShopRef.value?.fetchCardTypes(),
|
cardShopRef.value?.fetchCardTypes(),
|
||||||
|
productShopRef.value?.load(),
|
||||||
])
|
])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
212
packages/app/src/pages/shop/detail.vue
Normal file
212
packages/app/src/pages/shop/detail.vue
Normal file
@@ -0,0 +1,212 @@
|
|||||||
|
<template>
|
||||||
|
<view class="shop-page" :style="{ paddingTop: navBarHeight }">
|
||||||
|
<CustomNavBar title="好物详情" show-back />
|
||||||
|
<view v-if="error" class="state" @tap="load">{{ error }} · 点击重试</view>
|
||||||
|
<view v-else-if="!product" class="state">加载中…</view>
|
||||||
|
<template v-else>
|
||||||
|
<image class="hero" :src="product.coverUrl" mode="aspectFill" @tap="preview" />
|
||||||
|
<view class="content">
|
||||||
|
<text class="eyebrow">SELECTED FOR YOUR PRACTICE</text>
|
||||||
|
<view class="row">
|
||||||
|
<text class="headline">{{ product.title }}</text>
|
||||||
|
</view>
|
||||||
|
<view class="row pricing">
|
||||||
|
<text class="price">¥{{ shopMoney(product.price) }}</text>
|
||||||
|
<text class="muted">可售 {{ product.stock }} 件 · 已售 {{ product.sold }} 件</text>
|
||||||
|
</view>
|
||||||
|
<view class="notice">
|
||||||
|
到馆自提 · 付款后凭订单提货码领取
|
||||||
|
<text></text>
|
||||||
|
{{ studio.studioInfo?.address || '到馆领取前可联系场馆确认营业时间' }}
|
||||||
|
<text v-if="studio.studioInfo?.phone" @tap="call">· 联系场馆 ›</text>
|
||||||
|
</view>
|
||||||
|
<view class="panel">
|
||||||
|
<text class="label">关于这件好物</text>
|
||||||
|
<text class="description">{{ product.description || '详情请咨询馆内工作人员。' }}</text>
|
||||||
|
</view>
|
||||||
|
<view class="panel">
|
||||||
|
<view class="row">
|
||||||
|
<text class="label">购买数量</text>
|
||||||
|
<view class="stepper">
|
||||||
|
<button :disabled="quantity <= 1 || busy" @tap="quantity--">−</button>
|
||||||
|
<text>{{ quantity }}</text>
|
||||||
|
<button :disabled="quantity >= Math.min(product.stock, 99) || busy" @tap="quantity++">
|
||||||
|
+
|
||||||
|
</button>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
<view class="field">
|
||||||
|
<text class="label">取货人</text>
|
||||||
|
<input
|
||||||
|
v-model="name"
|
||||||
|
class="input"
|
||||||
|
maxlength="40"
|
||||||
|
placeholder="填写取货人姓名"
|
||||||
|
:disabled="busy"
|
||||||
|
/>
|
||||||
|
</view>
|
||||||
|
<view class="field">
|
||||||
|
<text class="label">联系电话</text>
|
||||||
|
<input
|
||||||
|
v-model="phone"
|
||||||
|
class="input"
|
||||||
|
type="number"
|
||||||
|
maxlength="11"
|
||||||
|
placeholder="用于取货联系"
|
||||||
|
:disabled="busy"
|
||||||
|
/>
|
||||||
|
</view>
|
||||||
|
<text class="muted">下单后保留库存 15 分钟;未付款超时自动取消。</text>
|
||||||
|
</view>
|
||||||
|
<view class="row">
|
||||||
|
<text class="muted" @tap="orders">查看我的商品订单 ›</text>
|
||||||
|
<button open-type="share" class="secondary">分享好物</button>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
<view class="buy-bar">
|
||||||
|
<view>
|
||||||
|
<text class="muted">合计 · {{ quantity }} 件</text>
|
||||||
|
<text class="price">¥{{ shopMoney(product.price * quantity) }}</text>
|
||||||
|
</view>
|
||||||
|
<button class="primary" :disabled="busy || !product.stock" :loading="busy" @tap="buy">
|
||||||
|
{{ !product.stock ? '暂时售罄' : user.loggedIn ? '立即购买' : '登录并购买' }}
|
||||||
|
</button>
|
||||||
|
</view>
|
||||||
|
</template>
|
||||||
|
</view>
|
||||||
|
</template>
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref } from 'vue'
|
||||||
|
import { onLoad, onShow, onShareAppMessage } from '@dcloudio/uni-app'
|
||||||
|
import type { ShopProduct, ShopOrder } from '@mp-pilates/shared'
|
||||||
|
import CustomNavBar from '../../components/CustomNavBar.vue'
|
||||||
|
import { getSystemLayout } from '../../utils/system'
|
||||||
|
import { get, post } from '../../utils/request'
|
||||||
|
import { shopMoney, shopError, payShopOrder } from '../../utils/shop'
|
||||||
|
import { useUserStore } from '../../stores/user'
|
||||||
|
import { useStudioStore } from '../../stores/studio'
|
||||||
|
const navBarHeight = `${getSystemLayout().navBarHeight}px`,
|
||||||
|
user = useUserStore(),
|
||||||
|
studio = useStudioStore()
|
||||||
|
const id = ref(''),
|
||||||
|
product = ref<ShopProduct | null>(null),
|
||||||
|
quantity = ref(1),
|
||||||
|
name = ref(user.user?.nickname || ''),
|
||||||
|
phone = ref(user.user?.phone || ''),
|
||||||
|
busy = ref(false),
|
||||||
|
error = ref('')
|
||||||
|
let attempt: { fingerprint: string; requestId: string } | null = null
|
||||||
|
async function load() {
|
||||||
|
if (!id.value) return
|
||||||
|
error.value = ''
|
||||||
|
try {
|
||||||
|
product.value = await get<ShopProduct>(`/shop/products/${id.value}`)
|
||||||
|
quantity.value = Math.max(1, Math.min(quantity.value, product.value.stock))
|
||||||
|
} catch (e) {
|
||||||
|
error.value = e instanceof Error ? e.message : '加载失败'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
async function buy() {
|
||||||
|
if (busy.value || !product.value) return
|
||||||
|
busy.value = true
|
||||||
|
let order: ShopOrder | undefined
|
||||||
|
try {
|
||||||
|
if (!user.loggedIn) {
|
||||||
|
const result = await user.loginWithSetup()
|
||||||
|
if (result.isNewUser) return
|
||||||
|
}
|
||||||
|
if (!name.value.trim() || !/^1[3-9]\d{9}$/.test(phone.value))
|
||||||
|
throw new Error('请填写取货人和有效的手机号码')
|
||||||
|
const data = {
|
||||||
|
productId: id.value,
|
||||||
|
expectedPrice: product.value.price,
|
||||||
|
quantity: quantity.value,
|
||||||
|
contactName: name.value.trim(),
|
||||||
|
contactPhone: phone.value,
|
||||||
|
}
|
||||||
|
const fingerprint = JSON.stringify(data)
|
||||||
|
if (!attempt || attempt.fingerprint !== fingerprint)
|
||||||
|
attempt = {
|
||||||
|
fingerprint,
|
||||||
|
requestId: `buy_${Date.now()}_${Math.random().toString(36).slice(2)}`,
|
||||||
|
}
|
||||||
|
order = await post<ShopOrder>('/shop/orders', { ...data, requestId: attempt.requestId })
|
||||||
|
attempt = null
|
||||||
|
await payShopOrder(order.id)
|
||||||
|
} catch (e) {
|
||||||
|
shopError(e)
|
||||||
|
} finally {
|
||||||
|
busy.value = false
|
||||||
|
if (!order) await load()
|
||||||
|
if (order) uni.navigateTo({ url: `/pages/shop/orders?id=${order.id}` })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const orders = () => uni.navigateTo({ url: '/pages/shop/orders' })
|
||||||
|
const preview = () => product.value && uni.previewImage({ urls: [product.value.coverUrl] })
|
||||||
|
const call = () =>
|
||||||
|
studio.studioInfo?.phone && uni.makePhoneCall({ phoneNumber: studio.studioInfo.phone })
|
||||||
|
onLoad((q) => {
|
||||||
|
id.value = q?.id || ''
|
||||||
|
})
|
||||||
|
onShow(() => {
|
||||||
|
load()
|
||||||
|
studio.fetchStudioInfo()
|
||||||
|
})
|
||||||
|
onShareAppMessage(() => ({
|
||||||
|
title: product.value?.title || '馆内好物',
|
||||||
|
path: `/pages/shop/detail?id=${id.value}`,
|
||||||
|
imageUrl: product.value?.coverUrl,
|
||||||
|
}))
|
||||||
|
</script>
|
||||||
|
<style scoped src="./shop.css"></style>
|
||||||
|
<style scoped lang="scss">
|
||||||
|
.shop-page {
|
||||||
|
padding-bottom: calc(180rpx + env(safe-area-inset-bottom));
|
||||||
|
}
|
||||||
|
.hero {
|
||||||
|
width: 100%;
|
||||||
|
height: 750rpx;
|
||||||
|
background: #eae5db;
|
||||||
|
}
|
||||||
|
.pricing {
|
||||||
|
margin: 24rpx 0;
|
||||||
|
}
|
||||||
|
.headline {
|
||||||
|
font-size: 42rpx;
|
||||||
|
}
|
||||||
|
.description {
|
||||||
|
white-space: pre-wrap;
|
||||||
|
font-size: 28rpx;
|
||||||
|
line-height: 1.9;
|
||||||
|
color: #89796b;
|
||||||
|
}
|
||||||
|
.stepper {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 24rpx;
|
||||||
|
}
|
||||||
|
.stepper button {
|
||||||
|
width: 64rpx;
|
||||||
|
height: 64rpx;
|
||||||
|
line-height: 60rpx;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
background: #f1eee8;
|
||||||
|
}
|
||||||
|
.buy-bar {
|
||||||
|
position: fixed;
|
||||||
|
bottom: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
padding: 24rpx 32rpx calc(24rpx + env(safe-area-inset-bottom));
|
||||||
|
background: #fff;
|
||||||
|
box-shadow: 0 -4rpx 30rpx #5b48300b;
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
z-index: 10;
|
||||||
|
}
|
||||||
|
.buy-bar .primary {
|
||||||
|
min-width: 280rpx;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
124
packages/app/src/pages/shop/index.vue
Normal file
124
packages/app/src/pages/shop/index.vue
Normal file
@@ -0,0 +1,124 @@
|
|||||||
|
<template>
|
||||||
|
<view class="shop-page" :style="{ paddingTop: navBarHeight }">
|
||||||
|
<CustomNavBar title="馆内好物" show-back />
|
||||||
|
<view class="content">
|
||||||
|
<text class="eyebrow">FOCUS CORE · STUDIO EDIT</text>
|
||||||
|
<view class="row">
|
||||||
|
<text class="headline">让每一次练习都更舒适</text>
|
||||||
|
<text class="badge" @tap="orders">我的订单 ›</text>
|
||||||
|
</view>
|
||||||
|
<text class="muted">为日常练习挑选的好物,来馆时顺手带走。</text>
|
||||||
|
<input
|
||||||
|
v-model="search"
|
||||||
|
class="input search"
|
||||||
|
placeholder="搜索瑜伽服、袜子等好物"
|
||||||
|
confirm-type="search"
|
||||||
|
@confirm="load(true)"
|
||||||
|
/>
|
||||||
|
<view v-if="error" class="state" @tap="load(true)">{{ error }} · 点击重试</view>
|
||||||
|
<view v-else-if="!items.length" class="state">
|
||||||
|
{{ loading ? '加载中…' : '暂无相关商品' }}
|
||||||
|
</view>
|
||||||
|
<view class="grid">
|
||||||
|
<view v-for="p in items" :key="p.id" class="product" @tap="detail(p.id)">
|
||||||
|
<view class="cover">
|
||||||
|
<image :src="p.coverUrl" mode="aspectFill" lazy-load />
|
||||||
|
<text v-if="!p.stock" class="sold-out">暂时售罄</text>
|
||||||
|
</view>
|
||||||
|
<text class="item-title">{{ p.title }}</text>
|
||||||
|
<view class="row">
|
||||||
|
<text class="price">¥{{ shopMoney(p.price) }}</text>
|
||||||
|
<text class="muted">{{ p.sold }} 件已售</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
<button
|
||||||
|
v-if="items.length < total"
|
||||||
|
class="secondary load-more"
|
||||||
|
:loading="loading"
|
||||||
|
@tap="load(false)"
|
||||||
|
>
|
||||||
|
加载更多
|
||||||
|
</button>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</template>
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref } from 'vue'
|
||||||
|
import { onShow } from '@dcloudio/uni-app'
|
||||||
|
import CustomNavBar from '../../components/CustomNavBar.vue'
|
||||||
|
import { getSystemLayout } from '../../utils/system'
|
||||||
|
import { get } from '../../utils/request'
|
||||||
|
import { shopMoney } from '../../utils/shop'
|
||||||
|
import type { ShopProduct, ShopPage } from '@mp-pilates/shared'
|
||||||
|
const navBarHeight = `${getSystemLayout().navBarHeight}px`
|
||||||
|
const items = ref<ShopProduct[]>([]),
|
||||||
|
search = ref(''),
|
||||||
|
page = ref(0),
|
||||||
|
total = ref(0),
|
||||||
|
loading = ref(false),
|
||||||
|
error = ref('')
|
||||||
|
async function load(reset: boolean) {
|
||||||
|
if (loading.value) return
|
||||||
|
loading.value = true
|
||||||
|
error.value = ''
|
||||||
|
try {
|
||||||
|
const result = await get<ShopPage<ShopProduct>>('/shop/products', {
|
||||||
|
page: reset ? 1 : page.value + 1,
|
||||||
|
search: search.value,
|
||||||
|
})
|
||||||
|
items.value = reset ? result.items : [...items.value, ...result.items]
|
||||||
|
total.value = result.total
|
||||||
|
page.value = result.page
|
||||||
|
} catch (e) {
|
||||||
|
error.value = e instanceof Error ? e.message : '加载失败'
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const detail = (id: string) => uni.navigateTo({ url: `/pages/shop/detail?id=${id}` })
|
||||||
|
const orders = () => uni.navigateTo({ url: '/pages/shop/orders' })
|
||||||
|
onShow(() => load(true))
|
||||||
|
</script>
|
||||||
|
<style scoped src="./shop.css"></style>
|
||||||
|
<style scoped lang="scss">
|
||||||
|
.headline {
|
||||||
|
white-space: pre-line;
|
||||||
|
margin: 12rpx 0 24rpx;
|
||||||
|
}
|
||||||
|
.grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
gap: 32rpx 22rpx;
|
||||||
|
}
|
||||||
|
.cover {
|
||||||
|
height: 380rpx;
|
||||||
|
background: #e8e4db;
|
||||||
|
border-radius: 20rpx;
|
||||||
|
overflow: hidden;
|
||||||
|
position: relative;
|
||||||
|
margin-bottom: 18rpx;
|
||||||
|
}
|
||||||
|
.cover image {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
.product .price {
|
||||||
|
font-size: 30rpx;
|
||||||
|
}
|
||||||
|
.product .muted {
|
||||||
|
font-size: 20rpx;
|
||||||
|
}
|
||||||
|
.sold-out {
|
||||||
|
position: absolute;
|
||||||
|
bottom: 20rpx;
|
||||||
|
left: 20rpx;
|
||||||
|
padding: 10rpx 18rpx;
|
||||||
|
background: #fbf9f6e8;
|
||||||
|
font-size: 22rpx;
|
||||||
|
border-radius: 8rpx;
|
||||||
|
}
|
||||||
|
.product .row {
|
||||||
|
margin-top: 12rpx;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
58
packages/app/src/pages/shop/orders.vue
Normal file
58
packages/app/src/pages/shop/orders.vue
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
<template>
|
||||||
|
<view class="shop-page" :style="{ paddingTop: navBarHeight }">
|
||||||
|
<CustomNavBar title="我的商品订单" show-back />
|
||||||
|
<view class="content">
|
||||||
|
<text class="eyebrow">YOUR STUDIO FINDS</text>
|
||||||
|
<text class="headline">把喜欢带回家</text>
|
||||||
|
<text class="muted">付款后到馆领取,提货码在订单内查看。</text>
|
||||||
|
<ShopOrders v-if="user.loggedIn" ref="orders" />
|
||||||
|
<view v-else class="state">
|
||||||
|
<text>登录后查看你的商品订单</text>
|
||||||
|
<button class="primary" :disabled="busy" @tap="login">微信登录</button>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</template>
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { nextTick, ref } from 'vue'
|
||||||
|
import { onLoad, onShow } from '@dcloudio/uni-app'
|
||||||
|
import CustomNavBar from '../../components/CustomNavBar.vue'
|
||||||
|
import ShopOrders from '../../components/ShopOrders.vue'
|
||||||
|
import { getSystemLayout } from '../../utils/system'
|
||||||
|
import { useUserStore } from '../../stores/user'
|
||||||
|
import { shopError } from '../../utils/shop'
|
||||||
|
const navBarHeight = `${getSystemLayout().navBarHeight}px`,
|
||||||
|
user = useUserStore(),
|
||||||
|
orders = ref<InstanceType<typeof ShopOrders> | null>(null),
|
||||||
|
busy = ref(false)
|
||||||
|
let syncId = ''
|
||||||
|
onLoad((q) => {
|
||||||
|
syncId = q?.id || ''
|
||||||
|
})
|
||||||
|
async function refresh() {
|
||||||
|
await nextTick()
|
||||||
|
if (syncId && orders.value) {
|
||||||
|
const id = syncId
|
||||||
|
syncId = ''
|
||||||
|
await orders.value.syncId(id)
|
||||||
|
} else await orders.value?.load(true)
|
||||||
|
}
|
||||||
|
async function login() {
|
||||||
|
busy.value = true
|
||||||
|
try {
|
||||||
|
await user.loginWithSetup()
|
||||||
|
await refresh()
|
||||||
|
} catch (e) {
|
||||||
|
shopError(e)
|
||||||
|
} finally {
|
||||||
|
busy.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
onShow(refresh)
|
||||||
|
</script>
|
||||||
|
<style scoped src="./shop.css"></style>
|
||||||
|
<style scoped lang="scss">
|
||||||
|
.headline {
|
||||||
|
margin-bottom: 16rpx;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
184
packages/app/src/pages/shop/shop.css
Normal file
184
packages/app/src/pages/shop/shop.css
Normal file
@@ -0,0 +1,184 @@
|
|||||||
|
.shop-page {
|
||||||
|
min-height: 100vh;
|
||||||
|
box-sizing: border-box;
|
||||||
|
background: #fbf9f6;
|
||||||
|
color: #4a4035;
|
||||||
|
padding-bottom: calc(50rpx + env(safe-area-inset-bottom));
|
||||||
|
}
|
||||||
|
.content {
|
||||||
|
padding: 32rpx;
|
||||||
|
}
|
||||||
|
.eyebrow {
|
||||||
|
display: block;
|
||||||
|
font-size: 19rpx;
|
||||||
|
letter-spacing: 4rpx;
|
||||||
|
color: #83917b;
|
||||||
|
margin-bottom: 16rpx;
|
||||||
|
}
|
||||||
|
.headline {
|
||||||
|
display: block;
|
||||||
|
font-family: serif;
|
||||||
|
font-size: 46rpx;
|
||||||
|
line-height: 1.3;
|
||||||
|
}
|
||||||
|
.muted {
|
||||||
|
display: block;
|
||||||
|
font-size: 25rpx;
|
||||||
|
color: #95897e;
|
||||||
|
line-height: 1.7;
|
||||||
|
}
|
||||||
|
.panel {
|
||||||
|
background: #fff;
|
||||||
|
border: 1rpx solid #ede7df;
|
||||||
|
border-radius: 24rpx;
|
||||||
|
padding: 28rpx;
|
||||||
|
margin-bottom: 24rpx;
|
||||||
|
}
|
||||||
|
.row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 20rpx;
|
||||||
|
}
|
||||||
|
.price {
|
||||||
|
font-size: 36rpx;
|
||||||
|
color: #60785e;
|
||||||
|
}
|
||||||
|
.primary,
|
||||||
|
.secondary {
|
||||||
|
border-radius: 16rpx;
|
||||||
|
font-size: 27rpx;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0 28rpx;
|
||||||
|
line-height: 84rpx;
|
||||||
|
}
|
||||||
|
.primary {
|
||||||
|
background: #627c6c;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
.secondary {
|
||||||
|
background: #f0ece5;
|
||||||
|
color: #6c6256;
|
||||||
|
}
|
||||||
|
.primary[disabled] {
|
||||||
|
background: #c5cbbf;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
button::after {
|
||||||
|
border: 0;
|
||||||
|
}
|
||||||
|
.state {
|
||||||
|
padding: 100rpx 32rpx;
|
||||||
|
text-align: center;
|
||||||
|
color: #988c80;
|
||||||
|
font-size: 28rpx;
|
||||||
|
}
|
||||||
|
.state button {
|
||||||
|
margin-top: 24rpx;
|
||||||
|
}
|
||||||
|
.field {
|
||||||
|
margin: 24rpx 0;
|
||||||
|
}
|
||||||
|
.label {
|
||||||
|
display: block;
|
||||||
|
font-size: 25rpx;
|
||||||
|
margin-bottom: 14rpx;
|
||||||
|
color: #716657;
|
||||||
|
}
|
||||||
|
.input {
|
||||||
|
box-sizing: border-box;
|
||||||
|
background: #f6f3ee;
|
||||||
|
border-radius: 12rpx;
|
||||||
|
padding: 22rpx;
|
||||||
|
font-size: 28rpx;
|
||||||
|
min-height: 86rpx;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
.textarea {
|
||||||
|
height: 220rpx;
|
||||||
|
}
|
||||||
|
.actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 16rpx;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: flex-end;
|
||||||
|
margin-top: 24rpx;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
.thumb {
|
||||||
|
width: 136rpx;
|
||||||
|
height: 156rpx;
|
||||||
|
flex-shrink: 0;
|
||||||
|
border-radius: 14rpx;
|
||||||
|
background: #e9e5dc;
|
||||||
|
}
|
||||||
|
.item-title {
|
||||||
|
font-size: 29rpx;
|
||||||
|
line-height: 1.5;
|
||||||
|
display: block;
|
||||||
|
word-break: break-all;
|
||||||
|
}
|
||||||
|
.badge {
|
||||||
|
display: inline-block;
|
||||||
|
font-size: 22rpx;
|
||||||
|
background: #edf2e9;
|
||||||
|
color: #60785e;
|
||||||
|
padding: 8rpx 16rpx;
|
||||||
|
border-radius: 8rpx;
|
||||||
|
}
|
||||||
|
.search {
|
||||||
|
margin: 24rpx 0;
|
||||||
|
}
|
||||||
|
.tabs {
|
||||||
|
display: flex;
|
||||||
|
gap: 12rpx;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
margin: 24rpx 0;
|
||||||
|
}
|
||||||
|
.tab {
|
||||||
|
padding: 14rpx 22rpx;
|
||||||
|
border-radius: 30rpx;
|
||||||
|
font-size: 24rpx;
|
||||||
|
background: #ede9e2;
|
||||||
|
color: #8f8276;
|
||||||
|
}
|
||||||
|
.tab.active {
|
||||||
|
background: #647c6b;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
.divider {
|
||||||
|
height: 1rpx;
|
||||||
|
background: #eee8e1;
|
||||||
|
margin: 24rpx 0;
|
||||||
|
}
|
||||||
|
.order-no {
|
||||||
|
font-size: 21rpx;
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
.notice {
|
||||||
|
padding: 22rpx;
|
||||||
|
background: #edf1e9;
|
||||||
|
border-radius: 14rpx;
|
||||||
|
color: #61745f;
|
||||||
|
font-size: 24rpx;
|
||||||
|
line-height: 1.7;
|
||||||
|
margin: 24rpx 0;
|
||||||
|
}
|
||||||
|
.error {
|
||||||
|
color: #aa5d43;
|
||||||
|
font-size: 25rpx;
|
||||||
|
display: block;
|
||||||
|
margin: 20rpx 0;
|
||||||
|
}
|
||||||
32
packages/app/src/utils/shop.ts
Normal file
32
packages/app/src/utils/shop.ts
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
import type { ShopOrderStatus, PaymentParams } from '@mp-pilates/shared'
|
||||||
|
import { post } from './request'
|
||||||
|
export const shopMoney = (fen: number) => (fen / 100).toFixed(2)
|
||||||
|
export const shopStatus: Record<ShopOrderStatus, string> = {
|
||||||
|
PENDING: '待付款',
|
||||||
|
PAID: '待到馆取货',
|
||||||
|
COMPLETED: '已取货',
|
||||||
|
CANCELLED: '已取消',
|
||||||
|
}
|
||||||
|
export const shopError = (error: unknown) =>
|
||||||
|
uni.showToast({
|
||||||
|
title: error instanceof Error ? error.message : '操作失败,请重试',
|
||||||
|
icon: 'none',
|
||||||
|
})
|
||||||
|
export async function payShopOrder(id: string) {
|
||||||
|
const params = await post<PaymentParams>(`/shop/orders/${id}/pay`)
|
||||||
|
await new Promise<void>((resolve, reject) =>
|
||||||
|
uni.requestPayment({
|
||||||
|
...params,
|
||||||
|
provider: 'wxpay',
|
||||||
|
success: () => resolve(),
|
||||||
|
fail: (error) =>
|
||||||
|
reject(
|
||||||
|
new Error(
|
||||||
|
error.errMsg.includes('cancel')
|
||||||
|
? '已取消支付,可在订单中继续付款'
|
||||||
|
: '支付未完成,请刷新订单确认状态',
|
||||||
|
),
|
||||||
|
),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE `products` (
|
||||||
|
`id` VARCHAR(191) NOT NULL,
|
||||||
|
`title` VARCHAR(80) NOT NULL,
|
||||||
|
`description` TEXT NOT NULL,
|
||||||
|
`cover_url` VARCHAR(1000) NOT NULL,
|
||||||
|
`price` INTEGER NOT NULL,
|
||||||
|
`stock` INTEGER NOT NULL DEFAULT 0,
|
||||||
|
`sold` INTEGER NOT NULL DEFAULT 0,
|
||||||
|
`is_active` BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
`sort_order` INTEGER NOT NULL DEFAULT 0,
|
||||||
|
`version` INTEGER NOT NULL DEFAULT 0,
|
||||||
|
`created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||||
|
`updated_at` DATETIME(3) NOT NULL,
|
||||||
|
|
||||||
|
INDEX `products_is_active_sort_order_idx`(`is_active`, `sort_order`),
|
||||||
|
PRIMARY KEY (`id`)
|
||||||
|
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE `product_orders` (
|
||||||
|
`id` VARCHAR(191) NOT NULL,
|
||||||
|
`order_no` VARCHAR(32) NOT NULL,
|
||||||
|
`request_id` VARCHAR(80) NOT NULL,
|
||||||
|
`user_id` VARCHAR(191) NOT NULL,
|
||||||
|
`product_id` VARCHAR(191) NOT NULL,
|
||||||
|
`title` VARCHAR(80) NOT NULL,
|
||||||
|
`cover_url` VARCHAR(1000) NOT NULL,
|
||||||
|
`unit_price` INTEGER NOT NULL,
|
||||||
|
`quantity` INTEGER NOT NULL,
|
||||||
|
`amount` INTEGER NOT NULL,
|
||||||
|
`contact_name` VARCHAR(40) NOT NULL,
|
||||||
|
`contact_phone` VARCHAR(20) NOT NULL,
|
||||||
|
`status` ENUM('PENDING', 'PAID', 'COMPLETED', 'CANCELLED') NOT NULL DEFAULT 'PENDING',
|
||||||
|
`pickup_code` VARCHAR(12) NOT NULL,
|
||||||
|
`wx_transaction_id` VARCHAR(191) NULL,
|
||||||
|
`expires_at` DATETIME(3) NOT NULL,
|
||||||
|
`paid_at` DATETIME(3) NULL,
|
||||||
|
`completed_at` DATETIME(3) NULL,
|
||||||
|
`completed_by` VARCHAR(191) NULL,
|
||||||
|
`created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||||
|
`updated_at` DATETIME(3) NOT NULL,
|
||||||
|
|
||||||
|
UNIQUE INDEX `product_orders_order_no_key`(`order_no`),
|
||||||
|
UNIQUE INDEX `product_orders_wx_transaction_id_key`(`wx_transaction_id`),
|
||||||
|
INDEX `product_orders_status_expires_at_idx`(`status`, `expires_at`),
|
||||||
|
INDEX `product_orders_user_id_created_at_idx`(`user_id`, `created_at`),
|
||||||
|
INDEX `product_orders_product_id_status_idx`(`product_id`, `status`),
|
||||||
|
UNIQUE INDEX `product_orders_user_id_request_id_key`(`user_id`, `request_id`),
|
||||||
|
PRIMARY KEY (`id`)
|
||||||
|
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE `product_stock_changes` (
|
||||||
|
`id` VARCHAR(191) NOT NULL,
|
||||||
|
`product_id` VARCHAR(191) NOT NULL,
|
||||||
|
`delta` INTEGER NOT NULL,
|
||||||
|
`reason` VARCHAR(200) NOT NULL,
|
||||||
|
`operator_id` VARCHAR(191) NOT NULL,
|
||||||
|
`created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||||
|
|
||||||
|
INDEX `product_stock_changes_product_id_created_at_idx`(`product_id`, `created_at`),
|
||||||
|
PRIMARY KEY (`id`)
|
||||||
|
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE `product_orders` ADD CONSTRAINT `product_orders_user_id_fkey` FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE `product_orders` ADD CONSTRAINT `product_orders_product_id_fkey` FOREIGN KEY (`product_id`) REFERENCES `products`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE `product_stock_changes` ADD CONSTRAINT `product_stock_changes_product_id_fkey` FOREIGN KEY (`product_id`) REFERENCES `products`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||||
|
|
||||||
@@ -73,6 +73,7 @@ model User {
|
|||||||
createdAt DateTime @default(now()) @map("created_at")
|
createdAt DateTime @default(now()) @map("created_at")
|
||||||
updatedAt DateTime @updatedAt @map("updated_at")
|
updatedAt DateTime @updatedAt @map("updated_at")
|
||||||
|
|
||||||
|
productOrders ProductOrder[]
|
||||||
bodyMetrics BodyMetric[]
|
bodyMetrics BodyMetric[]
|
||||||
memberNotes MemberNote[]
|
memberNotes MemberNote[]
|
||||||
progressPhotos ProgressPhoto[]
|
progressPhotos ProgressPhoto[]
|
||||||
@@ -620,3 +621,73 @@ model GrowthShareCard {
|
|||||||
@@index([userId, createdAt])
|
@@index([userId, createdAt])
|
||||||
@@map("growth_share_cards")
|
@@map("growth_share_cards")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Inventory counts sellable units; pending orders reserve units immediately.
|
||||||
|
enum ProductOrderStatus {
|
||||||
|
PENDING
|
||||||
|
PAID
|
||||||
|
COMPLETED
|
||||||
|
CANCELLED
|
||||||
|
}
|
||||||
|
|
||||||
|
model Product {
|
||||||
|
id String @id @default(uuid())
|
||||||
|
title String @db.VarChar(80)
|
||||||
|
description String @db.Text
|
||||||
|
coverUrl String @map("cover_url") @db.VarChar(1000)
|
||||||
|
price Int
|
||||||
|
stock Int @default(0)
|
||||||
|
sold Int @default(0)
|
||||||
|
isActive Boolean @default(false) @map("is_active")
|
||||||
|
sortOrder Int @default(0) @map("sort_order")
|
||||||
|
version Int @default(0)
|
||||||
|
createdAt DateTime @default(now()) @map("created_at")
|
||||||
|
updatedAt DateTime @updatedAt @map("updated_at")
|
||||||
|
orders ProductOrder[]
|
||||||
|
stockChanges ProductStockChange[]
|
||||||
|
@@index([isActive, sortOrder])
|
||||||
|
@@map("products")
|
||||||
|
}
|
||||||
|
|
||||||
|
model ProductOrder {
|
||||||
|
id String @id @default(uuid())
|
||||||
|
orderNo String @unique @map("order_no") @db.VarChar(32)
|
||||||
|
requestId String @map("request_id") @db.VarChar(80)
|
||||||
|
userId String @map("user_id")
|
||||||
|
productId String @map("product_id")
|
||||||
|
title String @db.VarChar(80)
|
||||||
|
coverUrl String @map("cover_url") @db.VarChar(1000)
|
||||||
|
unitPrice Int @map("unit_price")
|
||||||
|
quantity Int
|
||||||
|
amount Int
|
||||||
|
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")
|
||||||
|
completedAt DateTime? @map("completed_at")
|
||||||
|
completedBy String? @map("completed_by")
|
||||||
|
createdAt DateTime @default(now()) @map("created_at")
|
||||||
|
updatedAt DateTime @updatedAt @map("updated_at")
|
||||||
|
user User @relation(fields: [userId], references: [id])
|
||||||
|
product Product @relation(fields: [productId], references: [id])
|
||||||
|
@@unique([userId, requestId])
|
||||||
|
@@index([status, expiresAt])
|
||||||
|
@@index([userId, createdAt])
|
||||||
|
@@index([productId, status])
|
||||||
|
@@map("product_orders")
|
||||||
|
}
|
||||||
|
|
||||||
|
model ProductStockChange {
|
||||||
|
id String @id @default(uuid())
|
||||||
|
productId String @map("product_id")
|
||||||
|
delta Int
|
||||||
|
reason String @db.VarChar(200)
|
||||||
|
operatorId String @map("operator_id")
|
||||||
|
createdAt DateTime @default(now()) @map("created_at")
|
||||||
|
product Product @relation(fields: [productId], references: [id])
|
||||||
|
@@index([productId, createdAt])
|
||||||
|
@@map("product_stock_changes")
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { ShopModule } from './shop/shop.module'
|
||||||
import { Module } from '@nestjs/common'
|
import { Module } from '@nestjs/common'
|
||||||
import { ConfigModule } from '@nestjs/config'
|
import { ConfigModule } from '@nestjs/config'
|
||||||
import { AppController } from './app.controller'
|
import { AppController } from './app.controller'
|
||||||
@@ -29,6 +30,7 @@ import { BodyPortraitModule } from './body-portrait/body-portrait.module'
|
|||||||
BookingModule,
|
BookingModule,
|
||||||
SchedulerModule,
|
SchedulerModule,
|
||||||
PaymentModule,
|
PaymentModule,
|
||||||
|
ShopModule,
|
||||||
AdminModule,
|
AdminModule,
|
||||||
InviteModule,
|
InviteModule,
|
||||||
BodyPortraitModule,
|
BodyPortraitModule,
|
||||||
|
|||||||
@@ -0,0 +1,70 @@
|
|||||||
|
import { WechatPayService } from '../wechat-pay.service'
|
||||||
|
|
||||||
|
describe('WeChat product payment protocol', () => {
|
||||||
|
let service: WechatPayService, internal: any
|
||||||
|
beforeEach(() => {
|
||||||
|
// Exercise the real protocol methods without loading production keys.
|
||||||
|
service = Object.create(WechatPayService.prototype)
|
||||||
|
internal = service as any
|
||||||
|
Object.assign(internal, {
|
||||||
|
appId: 'app',
|
||||||
|
mchId: 'merchant',
|
||||||
|
mchSerialNo: 'serial',
|
||||||
|
notifyUrl: 'https://example.com/api/payment/wx-notify',
|
||||||
|
logger: { log: jest.fn(), error: jest.fn() },
|
||||||
|
signWithRSA: jest.fn().mockReturnValue('signed'),
|
||||||
|
})
|
||||||
|
})
|
||||||
|
afterEach(() => jest.restoreAllMocks())
|
||||||
|
it('signs GET requests including query and an empty body', async () => {
|
||||||
|
const fetchMock = jest.spyOn(global, 'fetch').mockResolvedValue({ ok: true } as Response)
|
||||||
|
await internal.httpRequestWithRSA(
|
||||||
|
'GET',
|
||||||
|
'https://api.mch.weixin.qq.com/v3/pay/transactions/out-trade-no/SP1?mchid=merchant',
|
||||||
|
{},
|
||||||
|
'nonce',
|
||||||
|
'123',
|
||||||
|
)
|
||||||
|
expect(internal.signWithRSA).toHaveBeenCalledWith(
|
||||||
|
'GET\n/v3/pay/transactions/out-trade-no/SP1?mchid=merchant\n123\nnonce\n\n',
|
||||||
|
)
|
||||||
|
expect(fetchMock.mock.calls[0][1]?.body).toBeUndefined()
|
||||||
|
})
|
||||||
|
it('sets product-specific callback and explicit payment deadline', async () => {
|
||||||
|
internal.httpRequestWithRSA = jest
|
||||||
|
.fn()
|
||||||
|
.mockResolvedValue({ ok: true, text: async () => JSON.stringify({ prepay_id: 'test' }) })
|
||||||
|
await service.createUnifiedOrder({
|
||||||
|
orderNo: 'SP1',
|
||||||
|
amount: 1900,
|
||||||
|
description: '袜子',
|
||||||
|
openid: 'buyer',
|
||||||
|
expiresAt: new Date('2026-09-16T13:00:00Z'),
|
||||||
|
})
|
||||||
|
expect(internal.httpRequestWithRSA.mock.calls[0][2]).toEqual(
|
||||||
|
expect.objectContaining({
|
||||||
|
notify_url: 'https://example.com/api/shop/wx-notify',
|
||||||
|
time_expire: '2026-09-16T13:00:00.000Z',
|
||||||
|
amount: { total: 1900, currency: 'CNY' },
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
it.each([{ appid: 'wrong' }, { mchid: 'wrong' }, { out_trade_no: 'wrong' }])(
|
||||||
|
'rejects query result for another merchant or order: %j',
|
||||||
|
async (patch) => {
|
||||||
|
internal.httpRequestWithRSA = jest
|
||||||
|
.fn()
|
||||||
|
.mockResolvedValue({
|
||||||
|
ok: true,
|
||||||
|
json: async () => ({ appid: 'app', mchid: 'merchant', out_trade_no: 'SP1', ...patch }),
|
||||||
|
})
|
||||||
|
await expect(service.queryProductOrder('SP1')).rejects.toThrow('查单失败')
|
||||||
|
},
|
||||||
|
)
|
||||||
|
it('does not treat a failed close as a cancelled order', async () => {
|
||||||
|
internal.httpRequestWithRSA = jest
|
||||||
|
.fn()
|
||||||
|
.mockResolvedValue({ ok: false, json: async () => ({ code: 'ORDERPAID' }) })
|
||||||
|
await expect(service.closeProductOrder('SP1')).rejects.toThrow('无法关闭')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -9,6 +9,8 @@ export interface UnifiedOrderParams {
|
|||||||
amount: number // in fen (分/ cents), e.g. ¥99.00 → 9900
|
amount: number // in fen (分/ cents), e.g. ¥99.00 → 9900
|
||||||
openid: string
|
openid: string
|
||||||
description: string
|
description: string
|
||||||
|
expiresAt?: Date
|
||||||
|
notifyUrl?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface WxPaymentParams {
|
export interface WxPaymentParams {
|
||||||
@@ -95,7 +97,8 @@ export class WechatPayService {
|
|||||||
mchid: this.mchId,
|
mchid: this.mchId,
|
||||||
description: params.description,
|
description: params.description,
|
||||||
out_trade_no: params.orderNo,
|
out_trade_no: params.orderNo,
|
||||||
notify_url: this.notifyUrl,
|
notify_url: params.notifyUrl ?? (params.orderNo.startsWith('SP') ? this.notifyUrl.replace('/payment/wx-notify', '/shop/wx-notify') : this.notifyUrl),
|
||||||
|
...(params.expiresAt ? { time_expire: params.expiresAt.toISOString() } : {}),
|
||||||
amount: {
|
amount: {
|
||||||
total: Math.round(params.amount), // amount is already in fen (cents)
|
total: Math.round(params.amount), // amount is already in fen (cents)
|
||||||
currency: 'CNY',
|
currency: 'CNY',
|
||||||
@@ -255,6 +258,32 @@ export class WechatPayService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Server-to-server reconciliation: never trust client payment success. */
|
||||||
|
async queryProductOrder(orderNo: string) {
|
||||||
|
const response = await this.httpRequestWithRSA('GET',
|
||||||
|
`${WECHAT_PAY_BASE_URL}/v3/pay/transactions/out-trade-no/${encodeURIComponent(orderNo)}?mchid=${this.mchId}`,
|
||||||
|
{}, crypto.randomBytes(16).toString('hex'), Math.floor(Date.now() / 1000).toString())
|
||||||
|
const data = await response.json() as {
|
||||||
|
code?: string; trade_state?: string; transaction_id?: string; out_trade_no?: string;
|
||||||
|
appid?: string; mchid?: string; amount?: { total: number; currency: string }; payer?: { openid: string }
|
||||||
|
}
|
||||||
|
if (data.code === 'ORDER_NOT_EXIST') return { state: 'NOT_FOUND' }
|
||||||
|
if (!response.ok || data.appid !== this.appId || data.mchid !== this.mchId || data.out_trade_no !== orderNo) {
|
||||||
|
throw new Error('微信查单失败,请稍后重试')
|
||||||
|
}
|
||||||
|
return { state: data.trade_state, transactionId: data.transaction_id,
|
||||||
|
amount: data.amount?.total, currency: data.amount?.currency, openid: data.payer?.openid }
|
||||||
|
}
|
||||||
|
|
||||||
|
async closeProductOrder(orderNo: string) {
|
||||||
|
const response = await this.httpRequestWithRSA('POST',
|
||||||
|
`${WECHAT_PAY_BASE_URL}/v3/pay/transactions/out-trade-no/${encodeURIComponent(orderNo)}/close`,
|
||||||
|
{ mchid: this.mchId }, crypto.randomBytes(16).toString('hex'), Math.floor(Date.now() / 1000).toString())
|
||||||
|
if (response.ok) return
|
||||||
|
const data = await response.json() as { code?: string }
|
||||||
|
if (data.code !== 'ORDERCLOSED') throw new Error('订单暂时无法关闭,请刷新支付状态后重试')
|
||||||
|
}
|
||||||
|
|
||||||
// ─── Private helpers ────────────────────────────────────────────────────────
|
// ─── Private helpers ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -267,10 +296,10 @@ export class WechatPayService {
|
|||||||
nonceStr: string,
|
nonceStr: string,
|
||||||
timestamp: string,
|
timestamp: string,
|
||||||
): Promise<Response> {
|
): Promise<Response> {
|
||||||
const bodyStr = JSON.stringify(body)
|
const bodyStr = method === 'GET' ? '' : JSON.stringify(body)
|
||||||
|
|
||||||
// Build signature string: {METHOD}\n{URL}\n{TIMESTAMP}\n{NONCE}\n{BODY}\n
|
// Build signature string: {METHOD}\n{URL}\n{TIMESTAMP}\n{NONCE}\n{BODY}\n
|
||||||
const urlPath = new URL(url).pathname // e.g. /v3/pay/transactions/jsapi
|
const urlPath = new URL(url).pathname + new URL(url).search // e.g. /v3/pay/transactions/jsapi
|
||||||
const signString = `${method}\n${urlPath}\n${timestamp}\n${nonceStr}\n${bodyStr}\n`
|
const signString = `${method}\n${urlPath}\n${timestamp}\n${nonceStr}\n${bodyStr}\n`
|
||||||
|
|
||||||
// Sign with merchant's RSA private key using SHA256 with RSA
|
// Sign with merchant's RSA private key using SHA256 with RSA
|
||||||
@@ -281,6 +310,7 @@ export class WechatPayService {
|
|||||||
|
|
||||||
const response = await fetch(url, {
|
const response = await fetch(url, {
|
||||||
method,
|
method,
|
||||||
|
signal: AbortSignal.timeout(15000),
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
'Authorization': authorization,
|
'Authorization': authorization,
|
||||||
|
|||||||
64
packages/server/src/shop/__tests__/shop.dto.spec.ts
Normal file
64
packages/server/src/shop/__tests__/shop.dto.spec.ts
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
import 'reflect-metadata'
|
||||||
|
import { plainToInstance } from 'class-transformer'
|
||||||
|
import { validate } from 'class-validator'
|
||||||
|
import { BuyProductDto, ProductDto, ShopQueryDto, StockDto } from '../dto/shop.dto'
|
||||||
|
|
||||||
|
describe('Shop API validation', () => {
|
||||||
|
const valid = {
|
||||||
|
productId: 'p1',
|
||||||
|
requestId: 'request_1',
|
||||||
|
quantity: 1,
|
||||||
|
expectedPrice: 1900,
|
||||||
|
contactName: '顾客',
|
||||||
|
contactPhone: '13800138000',
|
||||||
|
}
|
||||||
|
it.each([
|
||||||
|
{ quantity: 0 },
|
||||||
|
{ quantity: -1 },
|
||||||
|
{ quantity: 1.5 },
|
||||||
|
{ quantity: 100 },
|
||||||
|
{ contactName: ' ' },
|
||||||
|
{ contactPhone: '123' },
|
||||||
|
{ expectedPrice: 0 },
|
||||||
|
{ requestId: '' },
|
||||||
|
])('rejects invalid purchases: %j', async (patch) => {
|
||||||
|
expect(
|
||||||
|
(await validate(plainToInstance(BuyProductDto, { ...valid, ...patch }))).length,
|
||||||
|
).toBeGreaterThan(0)
|
||||||
|
})
|
||||||
|
it('does not accept a forged amount or user', async () => {
|
||||||
|
const errors = await validate(
|
||||||
|
plainToInstance(BuyProductDto, { ...valid, amount: 1, userId: 'victim' }),
|
||||||
|
{ whitelist: true, forbidNonWhitelisted: true },
|
||||||
|
)
|
||||||
|
expect(errors).toHaveLength(2)
|
||||||
|
})
|
||||||
|
it.each([{ page: 0 }, { page: 'NaN' }, { limit: 10000 }, { status: 'REFUNDED' }])(
|
||||||
|
'bounds order queries: %j',
|
||||||
|
async (query) => {
|
||||||
|
expect((await validate(plainToInstance(ShopQueryDto, query))).length).toBeGreaterThan(0)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
it('transforms valid page parameters', async () => {
|
||||||
|
const query = plainToInstance(ShopQueryDto, { page: '2', limit: '10' })
|
||||||
|
expect(await validate(query)).toHaveLength(0)
|
||||||
|
expect(query.page).toBe(2)
|
||||||
|
})
|
||||||
|
it('rejects non-HTTPS cover URLs and fractional money', async () => {
|
||||||
|
const errors = await validate(
|
||||||
|
plainToInstance(ProductDto, {
|
||||||
|
title: '袜子',
|
||||||
|
description: '',
|
||||||
|
coverUrl: 'javascript:alert(1)',
|
||||||
|
price: 1.2,
|
||||||
|
sortOrder: 0,
|
||||||
|
isActive: true,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
expect(errors.map((e) => e.property)).toEqual(expect.arrayContaining(['coverUrl', 'price']))
|
||||||
|
})
|
||||||
|
it('requires an explanation and version for inventory adjustments', async () => {
|
||||||
|
const errors = await validate(plainToInstance(StockDto, { delta: 1, reason: ' ' }))
|
||||||
|
expect(errors.map((e) => e.property)).toEqual(expect.arrayContaining(['reason', 'version']))
|
||||||
|
})
|
||||||
|
})
|
||||||
41
packages/server/src/shop/__tests__/shop.permissions.spec.ts
Normal file
41
packages/server/src/shop/__tests__/shop.permissions.spec.ts
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
import 'reflect-metadata'
|
||||||
|
import { Reflector } from '@nestjs/core'
|
||||||
|
import { GUARDS_METADATA } from '@nestjs/common/constants'
|
||||||
|
import { ExecutionContext } from '@nestjs/common'
|
||||||
|
import { JwtAuthGuard } from '../../auth/jwt-auth.guard'
|
||||||
|
import { RolesGuard } from '../../auth/roles.guard'
|
||||||
|
import { ShopAdminController, ShopController } from '../shop.controller'
|
||||||
|
|
||||||
|
describe('Shop route access policy', () => {
|
||||||
|
const reflector = new Reflector()
|
||||||
|
it('requires JWT and roles guard on the entire admin controller', () => {
|
||||||
|
expect(Reflect.getMetadata(GUARDS_METADATA, ShopAdminController)).toEqual([
|
||||||
|
JwtAuthGuard,
|
||||||
|
RolesGuard,
|
||||||
|
])
|
||||||
|
})
|
||||||
|
it.each(['create', 'orders', 'order', 'pay', 'sync', 'cancel'] as const)(
|
||||||
|
'requires login for customer %s',
|
||||||
|
(method) => {
|
||||||
|
expect(Reflect.getMetadata(GUARDS_METADATA, ShopController.prototype[method])).toContain(
|
||||||
|
JwtAuthGuard,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
it.each([undefined, { role: 'MEMBER' }])('rejects non-admin user %j from management', (user) => {
|
||||||
|
const context = {
|
||||||
|
getClass: () => ShopAdminController,
|
||||||
|
getHandler: () => ShopAdminController.prototype.create,
|
||||||
|
switchToHttp: () => ({ getRequest: () => ({ user }) }),
|
||||||
|
} as unknown as ExecutionContext
|
||||||
|
expect(new RolesGuard(reflector).canActivate(context)).toBe(false)
|
||||||
|
})
|
||||||
|
it('permits an administrator to manage products', () => {
|
||||||
|
const context = {
|
||||||
|
getClass: () => ShopAdminController,
|
||||||
|
getHandler: () => ShopAdminController.prototype.create,
|
||||||
|
switchToHttp: () => ({ getRequest: () => ({ user: { role: 'ADMIN' } }) }),
|
||||||
|
} as unknown as ExecutionContext
|
||||||
|
expect(new RolesGuard(reflector).canActivate(context)).toBe(true)
|
||||||
|
})
|
||||||
|
})
|
||||||
273
packages/server/src/shop/__tests__/shop.service.spec.ts
Normal file
273
packages/server/src/shop/__tests__/shop.service.spec.ts
Normal file
@@ -0,0 +1,273 @@
|
|||||||
|
import { ShopService } from '../shop.service'
|
||||||
|
import { ShopQueryDto } from '../dto/shop.dto'
|
||||||
|
|
||||||
|
const order = () => ({
|
||||||
|
id: 'o1',
|
||||||
|
orderNo: 'SPorder',
|
||||||
|
requestId: 'request_1',
|
||||||
|
userId: 'u1',
|
||||||
|
productId: 'p1',
|
||||||
|
title: '袜子',
|
||||||
|
coverUrl: 'https://example.com/sock.jpg',
|
||||||
|
unitPrice: 1900,
|
||||||
|
amount: 3800,
|
||||||
|
quantity: 2,
|
||||||
|
contactName: '顾客',
|
||||||
|
contactPhone: '13800138000',
|
||||||
|
status: 'PENDING',
|
||||||
|
pickupCode: 'ABCDEF123456',
|
||||||
|
expiresAt: new Date(Date.now() + 900000),
|
||||||
|
createdAt: new Date(),
|
||||||
|
updatedAt: new Date(),
|
||||||
|
paidAt: null,
|
||||||
|
completedAt: null,
|
||||||
|
completedBy: null,
|
||||||
|
wxTransactionId: null,
|
||||||
|
})
|
||||||
|
const product = () => ({
|
||||||
|
id: 'p1',
|
||||||
|
title: '袜子',
|
||||||
|
description: '',
|
||||||
|
coverUrl: 'https://example.com/sock.jpg',
|
||||||
|
price: 1900,
|
||||||
|
stock: 5,
|
||||||
|
sold: 0,
|
||||||
|
version: 0,
|
||||||
|
sortOrder: 0,
|
||||||
|
isActive: true,
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('ShopService inventory and payment', () => {
|
||||||
|
let prisma: any, pay: any, service: ShopService, current: any, item: any
|
||||||
|
beforeEach(() => {
|
||||||
|
current = order()
|
||||||
|
item = product()
|
||||||
|
prisma = {
|
||||||
|
user: { findUniqueOrThrow: jest.fn().mockResolvedValue({ openid: 'wx-user' }) },
|
||||||
|
product: {
|
||||||
|
findUnique: jest.fn(async () => ({ ...item })),
|
||||||
|
findMany: jest.fn().mockResolvedValue([]),
|
||||||
|
count: jest.fn().mockResolvedValue(0),
|
||||||
|
updateMany: jest.fn(async ({ where, data }) => {
|
||||||
|
if (where.version !== undefined && where.version !== item.version) return { count: 0 }
|
||||||
|
if (
|
||||||
|
where.stock?.gte > item.stock ||
|
||||||
|
(where.stock?.lte !== undefined && where.stock.lte < item.stock)
|
||||||
|
)
|
||||||
|
return { count: 0 }
|
||||||
|
if (where.isActive && !item.isActive) return { count: 0 }
|
||||||
|
if (data.stock?.decrement) item.stock -= data.stock.decrement
|
||||||
|
if (data.stock?.increment) item.stock += data.stock.increment
|
||||||
|
if (data.version?.increment) item.version += data.version.increment
|
||||||
|
return { count: 1 }
|
||||||
|
}),
|
||||||
|
update: jest.fn(async ({ data }) => {
|
||||||
|
if (data.stock?.increment) item.stock += data.stock.increment
|
||||||
|
if (data.sold?.increment) item.sold += data.sold.increment
|
||||||
|
return item
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
productOrder: {
|
||||||
|
findUnique: jest.fn().mockResolvedValue(null),
|
||||||
|
findFirst: jest.fn(async ({ where }) =>
|
||||||
|
where.userId === current.userId ? { ...current } : null,
|
||||||
|
),
|
||||||
|
findUniqueOrThrow: jest.fn(async () => ({ ...current })),
|
||||||
|
count: jest.fn().mockResolvedValue(0),
|
||||||
|
findMany: jest.fn().mockResolvedValue([]),
|
||||||
|
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 }
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
productStockChange: { create: jest.fn(), findMany: jest.fn() },
|
||||||
|
$queryRaw: jest.fn(),
|
||||||
|
$transaction: jest.fn(async (fn) => fn(prisma)),
|
||||||
|
}
|
||||||
|
pay = {
|
||||||
|
queryProductOrder: jest.fn().mockResolvedValue({
|
||||||
|
state: 'SUCCESS',
|
||||||
|
amount: 3800,
|
||||||
|
currency: 'CNY',
|
||||||
|
openid: 'wx-user',
|
||||||
|
transactionId: 'wx1',
|
||||||
|
}),
|
||||||
|
closeProductOrder: jest.fn(),
|
||||||
|
createUnifiedOrder: jest.fn().mockResolvedValue({ package: 'prepay_id=test' }),
|
||||||
|
parseNotification: jest.fn(),
|
||||||
|
}
|
||||||
|
service = new ShopService(prisma, pay)
|
||||||
|
})
|
||||||
|
const buy = {
|
||||||
|
productId: 'p1',
|
||||||
|
expectedPrice: 1900,
|
||||||
|
requestId: 'request_1',
|
||||||
|
quantity: 2,
|
||||||
|
contactName: '顾客',
|
||||||
|
contactPhone: '13800138000',
|
||||||
|
}
|
||||||
|
it('reserves inventory and snapshots server price', async () => {
|
||||||
|
const result = await service.createOrder('u1', buy)
|
||||||
|
expect(item.stock).toBe(3)
|
||||||
|
expect(result.amount).toBe(3800)
|
||||||
|
expect(result.title).toBe('袜子')
|
||||||
|
expect(result.orderNo).toMatch(/^SP[a-f0-9]{30}$/)
|
||||||
|
})
|
||||||
|
it('requires buyers to accept changed prices', async () => {
|
||||||
|
item.price = 2000
|
||||||
|
await expect(service.createOrder('u1', buy)).rejects.toThrow('价格已更新')
|
||||||
|
expect(prisma.productOrder.create).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
it('rejects insufficient stock without creating order', async () => {
|
||||||
|
item.stock = 1
|
||||||
|
await expect(service.createOrder('u1', buy)).rejects.toThrow('库存')
|
||||||
|
expect(prisma.productOrder.create).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
it('rejects inactive product', async () => {
|
||||||
|
item.isActive = false
|
||||||
|
await expect(service.createOrder('u1', buy)).rejects.toThrow('下架')
|
||||||
|
})
|
||||||
|
it('replays an idempotent purchase without reserving again', async () => {
|
||||||
|
prisma.productOrder.findUnique.mockResolvedValue(current)
|
||||||
|
expect(await service.createOrder('u1', buy)).toBe(current)
|
||||||
|
expect(prisma.product.updateMany).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
it('detects an idempotency key reused with another quantity', async () => {
|
||||||
|
prisma.productOrder.findUnique.mockResolvedValue(current)
|
||||||
|
await expect(service.createOrder('u1', { ...buy, quantity: 3 })).rejects.toThrow('不一致')
|
||||||
|
})
|
||||||
|
it('rechecks idempotency after acquiring the user lock', async () => {
|
||||||
|
prisma.productOrder.findUnique.mockResolvedValueOnce(null).mockResolvedValueOnce(current)
|
||||||
|
await service.createOrder('u1', buy)
|
||||||
|
expect(prisma.product.updateMany).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
it('limits outstanding reservations', async () => {
|
||||||
|
prisma.productOrder.count.mockResolvedValue(5)
|
||||||
|
await expect(service.createOrder('u1', buy)).rejects.toThrow('最多 5 笔')
|
||||||
|
})
|
||||||
|
it('cannot oversell the last units when two purchases race', async () => {
|
||||||
|
item.stock = 2
|
||||||
|
const results = await Promise.allSettled([
|
||||||
|
service.createOrder('u1', buy),
|
||||||
|
service.createOrder('u2', { ...buy, requestId: 'request_2' }),
|
||||||
|
])
|
||||||
|
expect(results.filter((r) => r.status === 'fulfilled')).toHaveLength(1)
|
||||||
|
expect(item.stock).toBe(0)
|
||||||
|
})
|
||||||
|
it('settles only once for duplicate/concurrent reconciliation', async () => {
|
||||||
|
const snapshot = { ...current }
|
||||||
|
await Promise.all([service.sync(snapshot), service.sync(snapshot)])
|
||||||
|
expect(current.status).toBe('PAID')
|
||||||
|
expect(item.sold).toBe(2)
|
||||||
|
expect(prisma.product.update).toHaveBeenCalledTimes(1)
|
||||||
|
})
|
||||||
|
it.each([{ amount: 1 }, { currency: 'USD' }, { openid: 'other' }, { transactionId: undefined }])(
|
||||||
|
'rejects mismatched payment %j',
|
||||||
|
async (patch) => {
|
||||||
|
pay.queryProductOrder.mockResolvedValue({
|
||||||
|
state: 'SUCCESS',
|
||||||
|
amount: 3800,
|
||||||
|
currency: 'CNY',
|
||||||
|
openid: 'wx-user',
|
||||||
|
transactionId: 'wx1',
|
||||||
|
...patch,
|
||||||
|
})
|
||||||
|
await expect(service.sync(current)).rejects.toThrow('不匹配')
|
||||||
|
expect(current.status).toBe('PENDING')
|
||||||
|
},
|
||||||
|
)
|
||||||
|
it('closes WeChat order before returning reserved inventory', async () => {
|
||||||
|
pay.queryProductOrder.mockResolvedValue({ state: 'NOTPAY' })
|
||||||
|
await service.sync({ ...current }, true)
|
||||||
|
expect(pay.closeProductOrder).toHaveBeenCalledWith('SPorder')
|
||||||
|
expect(current.status).toBe('CANCELLED')
|
||||||
|
expect(item.stock).toBe(7)
|
||||||
|
})
|
||||||
|
it('does not release when WeChat close fails', async () => {
|
||||||
|
pay.queryProductOrder.mockResolvedValue({ state: 'NOTPAY' })
|
||||||
|
pay.closeProductOrder.mockRejectedValue(new Error('network'))
|
||||||
|
await expect(service.sync(current, true)).rejects.toThrow('network')
|
||||||
|
expect(item.stock).toBe(5)
|
||||||
|
expect(current.status).toBe('PENDING')
|
||||||
|
})
|
||||||
|
it('does not release a not-yet-created WeChat order while prepay can be in flight', async () => {
|
||||||
|
pay.queryProductOrder.mockResolvedValue({ state: 'NOT_FOUND' })
|
||||||
|
await expect(service.sync(current, true)).rejects.toThrow('尚未同步')
|
||||||
|
expect(item.stock).toBe(5)
|
||||||
|
})
|
||||||
|
it('releases expired never-paid orders and only once', async () => {
|
||||||
|
current.expiresAt = new Date(Date.now() - 120000)
|
||||||
|
pay.queryProductOrder.mockResolvedValue({ state: 'NOT_FOUND' })
|
||||||
|
const snapshot = { ...current }
|
||||||
|
await Promise.all([service.sync(snapshot), service.sync(snapshot)])
|
||||||
|
expect(item.stock).toBe(7)
|
||||||
|
expect(current.status).toBe('CANCELLED')
|
||||||
|
})
|
||||||
|
it('settles a payment winning the cancellation race', async () => {
|
||||||
|
await service.sync(current, true)
|
||||||
|
expect(current.status).toBe('PAID')
|
||||||
|
expect(pay.closeProductOrder).not.toHaveBeenCalled()
|
||||||
|
expect(item.stock).toBe(5)
|
||||||
|
})
|
||||||
|
it('does not allow other users to pay or read an order', async () => {
|
||||||
|
await expect(service.payment('o1', 'other')).rejects.toThrow('不存在')
|
||||||
|
expect(pay.createUnifiedOrder).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
it('does not initiate payment after expiry', async () => {
|
||||||
|
current.expiresAt = new Date(0)
|
||||||
|
await expect(service.payment('o1', 'u1')).rejects.toThrow('过期')
|
||||||
|
})
|
||||||
|
it('creates payment with immutable order snapshot and deadline', async () => {
|
||||||
|
item.price = 9999
|
||||||
|
await service.payment('o1', 'u1')
|
||||||
|
expect(pay.createUnifiedOrder).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({ amount: 3800, expiresAt: current.expiresAt }),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
it('rejects stale inventory adjustments', async () => {
|
||||||
|
item.version = 2
|
||||||
|
await expect(
|
||||||
|
service.stock('p1', { version: 0, delta: 2, reason: '到货' }, 'admin'),
|
||||||
|
).rejects.toThrow('库存')
|
||||||
|
expect(prisma.productStockChange.create).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
it('rejects negative resulting inventory', async () => {
|
||||||
|
await expect(
|
||||||
|
service.stock('p1', { version: 0, delta: -6, reason: '盘亏' }, 'admin'),
|
||||||
|
).rejects.toThrow('库存')
|
||||||
|
})
|
||||||
|
it('records stock adjustments with operator and reason', async () => {
|
||||||
|
await service.stock('p1', { version: 0, delta: 2, reason: '到货' }, 'admin')
|
||||||
|
expect(item.stock).toBe(7)
|
||||||
|
expect(prisma.productStockChange.create).toHaveBeenCalledWith({
|
||||||
|
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('不可核销')
|
||||||
|
current.status = 'PAID'
|
||||||
|
await expect(service.complete('o1', 'WRONG', 'admin')).rejects.toThrow('不可核销')
|
||||||
|
await service.complete('o1', current.pickupCode, 'admin')
|
||||||
|
expect(current.status).toBe('COMPLETED')
|
||||||
|
expect(current.completedBy).toBe('admin')
|
||||||
|
await expect(service.complete('o1', current.pickupCode, 'admin')).rejects.toThrow('不可核销')
|
||||||
|
})
|
||||||
|
it('scopes user lists and hides inactive products publicly', async () => {
|
||||||
|
await service.orders(new ShopQueryDto(), 'u1')
|
||||||
|
expect(prisma.productOrder.findMany).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({ where: { userId: 'u1' } }),
|
||||||
|
)
|
||||||
|
await service.products(new ShopQueryDto())
|
||||||
|
expect(prisma.product.findMany).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({ where: { isActive: true } }),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
it('rejects invalid decrypted notifications', async () => {
|
||||||
|
pay.parseNotification.mockReturnValue({ success: false })
|
||||||
|
await expect(service.handleNotification({})).rejects.toThrow('Invalid')
|
||||||
|
expect(pay.queryProductOrder).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
})
|
||||||
67
packages/server/src/shop/dto/shop.dto.ts
Normal file
67
packages/server/src/shop/dto/shop.dto.ts
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
import { Type, Transform } from 'class-transformer'
|
||||||
|
import {
|
||||||
|
IsBoolean,
|
||||||
|
IsIn,
|
||||||
|
IsInt,
|
||||||
|
IsNotEmpty,
|
||||||
|
IsOptional,
|
||||||
|
IsString,
|
||||||
|
IsUrl,
|
||||||
|
Matches,
|
||||||
|
Max,
|
||||||
|
MaxLength,
|
||||||
|
Min,
|
||||||
|
} from 'class-validator'
|
||||||
|
|
||||||
|
export class ProductDto {
|
||||||
|
@IsString()
|
||||||
|
@Transform(({ value }) => (typeof value === 'string' ? value.trim() : value))
|
||||||
|
@IsNotEmpty()
|
||||||
|
@MaxLength(80)
|
||||||
|
title!: string
|
||||||
|
@IsString() @MaxLength(5000) description!: string
|
||||||
|
@IsUrl({ protocols: ['https'], require_protocol: true }) @MaxLength(1000) coverUrl!: string
|
||||||
|
@IsInt() @Min(1) @Max(10000000) price!: number
|
||||||
|
@IsBoolean() isActive!: boolean
|
||||||
|
@IsInt() @Min(0) @Max(99999) sortOrder!: number
|
||||||
|
@IsOptional() @IsInt() @Min(0) version?: number
|
||||||
|
}
|
||||||
|
export class StockDto {
|
||||||
|
@IsInt() @Min(-1000000) @Max(1000000) delta!: number
|
||||||
|
@IsInt() @Min(0) version!: number
|
||||||
|
@IsString()
|
||||||
|
@Transform(({ value }) => (typeof value === 'string' ? value.trim() : value))
|
||||||
|
@IsNotEmpty()
|
||||||
|
@MaxLength(200)
|
||||||
|
reason!: string
|
||||||
|
}
|
||||||
|
export class BuyProductDto {
|
||||||
|
@IsInt()
|
||||||
|
@Min(1)
|
||||||
|
@Max(10000000)
|
||||||
|
expectedPrice!: number
|
||||||
|
|
||||||
|
@IsString() @IsNotEmpty() @MaxLength(50) productId!: string
|
||||||
|
@IsString() @Matches(/^[a-zA-Z0-9_-]{8,80}$/) requestId!: string
|
||||||
|
@IsInt() @Min(1) @Max(99) quantity!: number
|
||||||
|
@IsString()
|
||||||
|
@Transform(({ value }) => (typeof value === 'string' ? value.trim() : value))
|
||||||
|
@IsNotEmpty()
|
||||||
|
@MaxLength(40)
|
||||||
|
contactName!: string
|
||||||
|
@IsString() @Matches(/^1[3-9]\d{9}$/) contactPhone!: string
|
||||||
|
}
|
||||||
|
export class ShopQueryDto {
|
||||||
|
@IsOptional() @Type(() => Number) @IsInt() @Min(1) @Max(100000) page = 1
|
||||||
|
@IsOptional() @Type(() => Number) @IsInt() @Min(1) @Max(50) limit = 20
|
||||||
|
@IsOptional() @IsIn(['PENDING', 'PAID', 'COMPLETED', 'CANCELLED']) status?:
|
||||||
|
| 'PENDING'
|
||||||
|
| 'PAID'
|
||||||
|
| 'COMPLETED'
|
||||||
|
| 'CANCELLED'
|
||||||
|
@IsOptional() @IsString() @MaxLength(80) search?: string
|
||||||
|
@IsOptional() @IsString() @MaxLength(50) productId?: string
|
||||||
|
}
|
||||||
|
export class PickupDto {
|
||||||
|
@IsString() @Matches(/^[A-F0-9]{12}$/) pickupCode!: string
|
||||||
|
}
|
||||||
109
packages/server/src/shop/shop.controller.ts
Normal file
109
packages/server/src/shop/shop.controller.ts
Normal file
@@ -0,0 +1,109 @@
|
|||||||
|
import type { Response } from 'express'
|
||||||
|
import {
|
||||||
|
BadRequestException,
|
||||||
|
Res,
|
||||||
|
Body,
|
||||||
|
Controller,
|
||||||
|
Get,
|
||||||
|
Param,
|
||||||
|
Post,
|
||||||
|
Put,
|
||||||
|
Query,
|
||||||
|
UseGuards,
|
||||||
|
} from '@nestjs/common'
|
||||||
|
import { UserRole } from '@mp-pilates/shared'
|
||||||
|
import { JwtAuthGuard } from '../auth/jwt-auth.guard'
|
||||||
|
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'
|
||||||
|
|
||||||
|
@Controller('shop')
|
||||||
|
export class ShopController {
|
||||||
|
constructor(private readonly shop: ShopService) {}
|
||||||
|
@Post('wx-notify')
|
||||||
|
async notify(@Body() body: Record<string, unknown>, @Res() response: Response) {
|
||||||
|
if (!body.resource) throw new BadRequestException('Invalid payment notification')
|
||||||
|
await this.shop.handleNotification(body.resource)
|
||||||
|
response.status(204).send()
|
||||||
|
}
|
||||||
|
@Get('products') products(@Query() query: ShopQueryDto) {
|
||||||
|
return this.shop.products(query)
|
||||||
|
}
|
||||||
|
@Get('products/:id') product(@Param('id') id: string) {
|
||||||
|
return this.shop.product(id)
|
||||||
|
}
|
||||||
|
@Post('orders')
|
||||||
|
@UseGuards(JwtAuthGuard)
|
||||||
|
create(@CurrentUser('sub') userId: string, @Body() dto: BuyProductDto) {
|
||||||
|
return this.shop.createOrder(userId, dto)
|
||||||
|
}
|
||||||
|
@Get('orders')
|
||||||
|
@UseGuards(JwtAuthGuard)
|
||||||
|
orders(@CurrentUser('sub') userId: string, @Query() query: ShopQueryDto) {
|
||||||
|
return this.shop.orders(query, userId)
|
||||||
|
}
|
||||||
|
@Get('orders/:id')
|
||||||
|
@UseGuards(JwtAuthGuard)
|
||||||
|
order(@Param('id') id: string, @CurrentUser('sub') userId: string) {
|
||||||
|
return this.shop.ownOrder(id, userId)
|
||||||
|
}
|
||||||
|
@Post('orders/:id/pay')
|
||||||
|
@UseGuards(JwtAuthGuard)
|
||||||
|
pay(@Param('id') id: string, @CurrentUser('sub') userId: string) {
|
||||||
|
return this.shop.payment(id, userId)
|
||||||
|
}
|
||||||
|
@Post('orders/:id/sync')
|
||||||
|
@UseGuards(JwtAuthGuard)
|
||||||
|
async sync(@Param('id') id: string, @CurrentUser('sub') userId: string) {
|
||||||
|
return this.shop.sync(await this.shop.ownOrder(id, userId))
|
||||||
|
}
|
||||||
|
@Post('orders/:id/cancel')
|
||||||
|
@UseGuards(JwtAuthGuard)
|
||||||
|
async cancel(@Param('id') id: string, @CurrentUser('sub') userId: string) {
|
||||||
|
return this.shop.sync(await this.shop.ownOrder(id, userId), true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Controller('admin/shop')
|
||||||
|
@UseGuards(JwtAuthGuard, RolesGuard)
|
||||||
|
@Roles(UserRole.ADMIN)
|
||||||
|
export class ShopAdminController {
|
||||||
|
constructor(private readonly shop: ShopService) {}
|
||||||
|
@Get('summary') summary() {
|
||||||
|
return this.shop.summary()
|
||||||
|
}
|
||||||
|
@Get('products') products(@Query() query: ShopQueryDto) {
|
||||||
|
return this.shop.products(query, true)
|
||||||
|
}
|
||||||
|
@Get('products/:id') product(@Param('id') id: string) {
|
||||||
|
return this.shop.product(id, true)
|
||||||
|
}
|
||||||
|
@Post('products') create(@Body() dto: ProductDto) {
|
||||||
|
return this.shop.saveProduct(dto)
|
||||||
|
}
|
||||||
|
@Put('products/:id') update(@Param('id') id: string, @Body() dto: ProductDto) {
|
||||||
|
return this.shop.saveProduct(dto, id)
|
||||||
|
}
|
||||||
|
@Post('products/:id/stock') stock(
|
||||||
|
@Param('id') id: string,
|
||||||
|
@Body() dto: StockDto,
|
||||||
|
@CurrentUser('sub') userId: string,
|
||||||
|
) {
|
||||||
|
return this.shop.stock(id, dto, userId)
|
||||||
|
}
|
||||||
|
@Get('products/:id/stock') history(@Param('id') id: string) {
|
||||||
|
return this.shop.stockHistory(id)
|
||||||
|
}
|
||||||
|
@Get('orders') orders(@Query() query: ShopQueryDto) {
|
||||||
|
return this.shop.orders(query)
|
||||||
|
}
|
||||||
|
@Post('orders/:id/complete') complete(
|
||||||
|
@Param('id') id: string,
|
||||||
|
@Body() dto: PickupDto,
|
||||||
|
@CurrentUser('sub') userId: string,
|
||||||
|
) {
|
||||||
|
return this.shop.complete(id, dto.pickupCode, userId)
|
||||||
|
}
|
||||||
|
}
|
||||||
12
packages/server/src/shop/shop.module.ts
Normal file
12
packages/server/src/shop/shop.module.ts
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
import { Module } from '@nestjs/common'
|
||||||
|
import { PrismaModule } from '../prisma/prisma.module'
|
||||||
|
import { WechatPayService } from '../payment/wechat-pay.service'
|
||||||
|
import { ShopController, ShopAdminController } from './shop.controller'
|
||||||
|
import { ShopService } from './shop.service'
|
||||||
|
@Module({
|
||||||
|
imports: [PrismaModule],
|
||||||
|
controllers: [ShopController, ShopAdminController],
|
||||||
|
providers: [ShopService, WechatPayService],
|
||||||
|
exports: [ShopService],
|
||||||
|
})
|
||||||
|
export class ShopModule {}
|
||||||
333
packages/server/src/shop/shop.service.ts
Normal file
333
packages/server/src/shop/shop.service.ts
Normal file
@@ -0,0 +1,333 @@
|
|||||||
|
import {
|
||||||
|
BadRequestException,
|
||||||
|
ConflictException,
|
||||||
|
Injectable,
|
||||||
|
Logger,
|
||||||
|
NotFoundException,
|
||||||
|
} from '@nestjs/common'
|
||||||
|
import { Cron } from '@nestjs/schedule'
|
||||||
|
import { Prisma, ProductOrder } from '@prisma/client'
|
||||||
|
import { randomBytes, 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'
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class ShopService {
|
||||||
|
private readonly logger = new Logger(ShopService.name)
|
||||||
|
private reconciling = false
|
||||||
|
constructor(
|
||||||
|
private readonly prisma: PrismaService,
|
||||||
|
private readonly pay: WechatPayService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async products(query: ShopQueryDto, admin = false) {
|
||||||
|
const where: Prisma.ProductWhereInput = {
|
||||||
|
...(admin ? {} : { isActive: true }),
|
||||||
|
...(query.search ? { title: { contains: query.search } } : {}),
|
||||||
|
}
|
||||||
|
const [items, total] = await Promise.all([
|
||||||
|
this.prisma.product.findMany({
|
||||||
|
where,
|
||||||
|
orderBy: [{ sortOrder: 'asc' }, { createdAt: 'desc' }],
|
||||||
|
skip: (query.page - 1) * query.limit,
|
||||||
|
take: query.limit,
|
||||||
|
}),
|
||||||
|
this.prisma.product.count({ where }),
|
||||||
|
])
|
||||||
|
return { items, total, page: query.page, limit: query.limit }
|
||||||
|
}
|
||||||
|
|
||||||
|
async product(id: string, admin = false) {
|
||||||
|
const product = await this.prisma.product.findUnique({ where: { id } })
|
||||||
|
if (!product || (!admin && !product.isActive)) throw new NotFoundException('商品已下架或不存在')
|
||||||
|
return product
|
||||||
|
}
|
||||||
|
|
||||||
|
async saveProduct(dto: ProductDto, id?: string) {
|
||||||
|
const { version, ...data } = dto
|
||||||
|
if (!id) return this.prisma.product.create({ data })
|
||||||
|
if (version === undefined) throw new BadRequestException('请刷新商品后再保存')
|
||||||
|
const result = await this.prisma.product.updateMany({
|
||||||
|
where: { id, version },
|
||||||
|
data: { ...data, version: { increment: 1 } },
|
||||||
|
})
|
||||||
|
if (!result.count) throw new ConflictException('商品或库存已变化,请刷新后重试')
|
||||||
|
return this.product(id, true)
|
||||||
|
}
|
||||||
|
|
||||||
|
async stock(id: string, dto: StockDto, operatorId: string) {
|
||||||
|
if (!dto.delta) throw new BadRequestException('调整数量不能为零')
|
||||||
|
return this.prisma.$transaction(async (tx) => {
|
||||||
|
const result = await tx.product.updateMany({
|
||||||
|
where: {
|
||||||
|
id,
|
||||||
|
version: dto.version,
|
||||||
|
stock: { gte: Math.max(0, -dto.delta), lte: 1000000 - Math.max(0, dto.delta) },
|
||||||
|
},
|
||||||
|
data: { stock: { increment: dto.delta }, version: { increment: 1 } },
|
||||||
|
})
|
||||||
|
if (!result.count) throw new ConflictException('库存已变化或数量超出范围,请刷新后重试')
|
||||||
|
await tx.productStockChange.create({
|
||||||
|
data: { productId: id, delta: dto.delta, reason: dto.reason, operatorId },
|
||||||
|
})
|
||||||
|
return tx.product.findUnique({ where: { id } })
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
stockHistory(productId: string) {
|
||||||
|
return this.prisma.productStockChange.findMany({
|
||||||
|
where: { productId },
|
||||||
|
orderBy: { createdAt: 'desc' },
|
||||||
|
take: 50,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async createOrder(userId: string, dto: BuyProductDto) {
|
||||||
|
const key = { userId_requestId: { userId, requestId: dto.requestId } }
|
||||||
|
const existing = await this.prisma.productOrder.findUnique({ where: key })
|
||||||
|
if (existing) {
|
||||||
|
if (
|
||||||
|
existing.productId !== dto.productId ||
|
||||||
|
existing.quantity !== dto.quantity ||
|
||||||
|
existing.contactName !== dto.contactName ||
|
||||||
|
existing.contactPhone !== dto.contactPhone
|
||||||
|
) {
|
||||||
|
throw new ConflictException('重复请求内容不一致,请重新下单')
|
||||||
|
}
|
||||||
|
return existing
|
||||||
|
}
|
||||||
|
const { expectedPrice, ...orderData } = dto
|
||||||
|
try {
|
||||||
|
return await this.prisma.$transaction(async (tx) => {
|
||||||
|
// Limit unpaid inventory hoarding and serialize purchases by the same user.
|
||||||
|
await tx.$queryRaw`SELECT id FROM users WHERE id = ${userId} FOR UPDATE`
|
||||||
|
const duplicate = await tx.productOrder.findUnique({ where: key })
|
||||||
|
if (duplicate) {
|
||||||
|
if (
|
||||||
|
duplicate.productId !== dto.productId ||
|
||||||
|
duplicate.quantity !== dto.quantity ||
|
||||||
|
duplicate.contactName !== dto.contactName ||
|
||||||
|
duplicate.contactPhone !== dto.contactPhone
|
||||||
|
)
|
||||||
|
throw new ConflictException('重复请求内容不一致,请重新下单')
|
||||||
|
return duplicate
|
||||||
|
}
|
||||||
|
if ((await tx.productOrder.count({ where: { userId, status: 'PENDING' } })) >= 5) {
|
||||||
|
throw new BadRequestException('请先支付或取消已有待付款订单(最多 5 笔)')
|
||||||
|
}
|
||||||
|
const product = await tx.product.findUnique({ where: { id: dto.productId } })
|
||||||
|
if (!product?.isActive) throw new BadRequestException('商品已下架')
|
||||||
|
if (product.price !== expectedPrice)
|
||||||
|
throw new ConflictException('商品价格已更新,请确认最新价格后重新购买')
|
||||||
|
const reserved = await tx.product.updateMany({
|
||||||
|
where: {
|
||||||
|
id: product.id,
|
||||||
|
isActive: true,
|
||||||
|
version: product.version,
|
||||||
|
stock: { gte: dto.quantity },
|
||||||
|
},
|
||||||
|
data: { stock: { decrement: dto.quantity }, version: { increment: 1 } },
|
||||||
|
})
|
||||||
|
if (!reserved.count) throw new ConflictException('商品或库存已变化,请刷新后重试')
|
||||||
|
return tx.productOrder.create({
|
||||||
|
data: {
|
||||||
|
...orderData,
|
||||||
|
userId,
|
||||||
|
orderNo: `SP${randomUUID().replace(/-/g, '').slice(0, 30)}`,
|
||||||
|
title: product.title,
|
||||||
|
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),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof Prisma.PrismaClientKnownRequestError && error.code === 'P2002') {
|
||||||
|
const order = await this.prisma.productOrder.findUnique({ where: key })
|
||||||
|
if (order) return order
|
||||||
|
}
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async ownOrder(id: string, userId: string) {
|
||||||
|
const order = await this.prisma.productOrder.findFirst({ where: { id, userId } })
|
||||||
|
if (!order) throw new NotFoundException('订单不存在')
|
||||||
|
return order
|
||||||
|
}
|
||||||
|
|
||||||
|
async payment(id: string, userId: string) {
|
||||||
|
const order = await this.ownOrder(id, userId)
|
||||||
|
if (order.status !== 'PENDING' || order.expiresAt.getTime() <= Date.now())
|
||||||
|
throw new BadRequestException('订单已支付或已过期,请刷新订单')
|
||||||
|
const user = await this.prisma.user.findUniqueOrThrow({ where: { id: userId } })
|
||||||
|
return this.pay.createUnifiedOrder({
|
||||||
|
orderNo: order.orderNo,
|
||||||
|
amount: order.amount,
|
||||||
|
openid: user.openid,
|
||||||
|
description: order.title,
|
||||||
|
expiresAt: order.expiresAt,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async sync(order: ProductOrder, cancel = false) {
|
||||||
|
if (order.status !== 'PENDING') return order
|
||||||
|
const result = await this.pay.queryProductOrder(order.orderNo)
|
||||||
|
if (result.state === 'SUCCESS') {
|
||||||
|
const user = await this.prisma.user.findUniqueOrThrow({ where: { id: order.userId } })
|
||||||
|
if (
|
||||||
|
result.amount !== order.amount ||
|
||||||
|
result.currency !== 'CNY' ||
|
||||||
|
result.openid !== user.openid ||
|
||||||
|
!result.transactionId
|
||||||
|
) {
|
||||||
|
this.logger.error(`Product payment mismatch: ${order.orderNo}`)
|
||||||
|
throw new BadRequestException('支付信息不匹配,请联系场馆')
|
||||||
|
}
|
||||||
|
await this.prisma.$transaction(async (tx) => {
|
||||||
|
const changed = await tx.productOrder.updateMany({
|
||||||
|
where: { id: order.id, status: 'PENDING' },
|
||||||
|
data: { status: 'PAID', paidAt: new Date(), wxTransactionId: result.transactionId },
|
||||||
|
})
|
||||||
|
if (changed.count)
|
||||||
|
await tx.product.update({
|
||||||
|
where: { id: order.productId },
|
||||||
|
data: { sold: { increment: order.quantity } },
|
||||||
|
})
|
||||||
|
})
|
||||||
|
} else if (result.state === 'CLOSED' || result.state === 'REVOKED') {
|
||||||
|
await this.release(order)
|
||||||
|
} else if (cancel || order.expiresAt.getTime() + 60000 < Date.now()) {
|
||||||
|
if (result.state === 'NOT_FOUND') {
|
||||||
|
// A prepay request may still be in flight. Never release a payable order.
|
||||||
|
if (order.expiresAt.getTime() + 60000 >= Date.now())
|
||||||
|
throw new BadRequestException('订单尚未同步,请稍后取消;超时后会自动释放库存')
|
||||||
|
} else {
|
||||||
|
await this.pay.closeProductOrder(order.orderNo)
|
||||||
|
}
|
||||||
|
await this.release(order)
|
||||||
|
} else {
|
||||||
|
await this.prisma.productOrder.updateMany({
|
||||||
|
where: { id: order.id, status: 'PENDING' },
|
||||||
|
data: { updatedAt: new Date() },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return this.prisma.productOrder.findUniqueOrThrow({ where: { id: order.id } })
|
||||||
|
}
|
||||||
|
|
||||||
|
private async release(order: ProductOrder) {
|
||||||
|
await this.prisma.$transaction(async (tx) => {
|
||||||
|
const changed = await tx.productOrder.updateMany({
|
||||||
|
where: { id: order.id, status: 'PENDING' },
|
||||||
|
data: { status: 'CANCELLED' },
|
||||||
|
})
|
||||||
|
if (changed.count)
|
||||||
|
await tx.product.update({
|
||||||
|
where: { id: order.productId },
|
||||||
|
data: { stock: { increment: order.quantity }, version: { increment: 1 } },
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async handleNotification(resource: unknown) {
|
||||||
|
// Decrypt authenticated AES-GCM resource; ignore any plaintext fields.
|
||||||
|
// Settlement still requires an independent server-to-server query.
|
||||||
|
const notification = this.pay.parseNotification({ resource })
|
||||||
|
if (!notification.success || !notification.orderNo.startsWith('SP'))
|
||||||
|
throw new BadRequestException('Invalid payment notification')
|
||||||
|
await this.notify(notification.orderNo)
|
||||||
|
}
|
||||||
|
|
||||||
|
async notify(orderNo: string) {
|
||||||
|
const order = await this.prisma.productOrder.findUnique({ where: { orderNo } })
|
||||||
|
if (order) await this.sync(order)
|
||||||
|
}
|
||||||
|
|
||||||
|
async orders(query: ShopQueryDto, userId?: string) {
|
||||||
|
const where: Prisma.ProductOrderWhereInput = {
|
||||||
|
...(userId ? { userId } : {}),
|
||||||
|
...(query.status ? { status: query.status } : {}),
|
||||||
|
...(query.productId ? { productId: query.productId } : {}),
|
||||||
|
...(query.search
|
||||||
|
? {
|
||||||
|
OR: [
|
||||||
|
{ title: { contains: query.search } },
|
||||||
|
{ orderNo: { contains: query.search } },
|
||||||
|
{ contactPhone: { contains: query.search } },
|
||||||
|
],
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
|
}
|
||||||
|
const [items, total] = await Promise.all([
|
||||||
|
this.prisma.productOrder.findMany({
|
||||||
|
where,
|
||||||
|
orderBy: { createdAt: 'desc' },
|
||||||
|
skip: (query.page - 1) * query.limit,
|
||||||
|
take: query.limit,
|
||||||
|
}),
|
||||||
|
this.prisma.productOrder.count({ where }),
|
||||||
|
])
|
||||||
|
return { items, total, page: query.page, limit: query.limit }
|
||||||
|
}
|
||||||
|
|
||||||
|
async complete(id: string, pickupCode: string, operatorId: string) {
|
||||||
|
const result = await this.prisma.productOrder.updateMany({
|
||||||
|
where: { id, pickupCode, status: 'PAID' },
|
||||||
|
data: { status: 'COMPLETED', completedAt: new Date(), completedBy: operatorId },
|
||||||
|
})
|
||||||
|
if (!result.count) throw new BadRequestException('提货码不正确,或订单不可核销')
|
||||||
|
return this.prisma.productOrder.findUniqueOrThrow({ where: { id } })
|
||||||
|
}
|
||||||
|
|
||||||
|
async summary() {
|
||||||
|
const [sales, pendingPickup, activeProducts, lowStock] = await Promise.all([
|
||||||
|
this.prisma.productOrder.aggregate({
|
||||||
|
where: { status: { in: ['PAID', 'COMPLETED'] } },
|
||||||
|
_sum: { amount: true, quantity: true },
|
||||||
|
_count: true,
|
||||||
|
}),
|
||||||
|
this.prisma.productOrder.count({ where: { status: 'PAID' } }),
|
||||||
|
this.prisma.product.count({ where: { isActive: true } }),
|
||||||
|
this.prisma.product.count({ where: { isActive: true, stock: { lte: 5 } } }),
|
||||||
|
])
|
||||||
|
return {
|
||||||
|
revenue: sales._sum.amount ?? 0,
|
||||||
|
quantity: sales._sum.quantity ?? 0,
|
||||||
|
orders: sales._count,
|
||||||
|
pendingPickup,
|
||||||
|
activeProducts,
|
||||||
|
lowStock,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Cron('*/30 * * * * *')
|
||||||
|
async reconcile() {
|
||||||
|
if (this.reconciling) return
|
||||||
|
this.reconciling = true
|
||||||
|
try {
|
||||||
|
const orders = await this.prisma.productOrder.findMany({
|
||||||
|
where: { status: 'PENDING' },
|
||||||
|
orderBy: { updatedAt: 'asc' },
|
||||||
|
take: 50,
|
||||||
|
})
|
||||||
|
for (const order of orders) {
|
||||||
|
try {
|
||||||
|
await this.sync(order)
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.warn(
|
||||||
|
`Product order reconciliation failed: ${order.orderNo}: ${error instanceof Error ? error.message : 'unknown'}`,
|
||||||
|
)
|
||||||
|
await this.prisma.productOrder.updateMany({
|
||||||
|
where: { id: order.id, status: 'PENDING' },
|
||||||
|
data: { updatedAt: new Date() },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
this.reconciling = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -10,6 +10,6 @@ export class CreateStudioUploadCredentialDto {
|
|||||||
contentType?: string
|
contentType?: string
|
||||||
|
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsIn(['gallery', 'logo', 'banner', 'card-cover'])
|
@IsIn(['gallery', 'logo', 'banner', 'product-cover', 'card-cover'])
|
||||||
assetType?: StudioAssetType
|
assetType?: StudioAssetType
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -138,3 +138,5 @@ export type {
|
|||||||
|
|
||||||
export * from './types/member-care'
|
export * from './types/member-care'
|
||||||
export * from './types/body-portrait'
|
export * from './types/body-portrait'
|
||||||
|
|
||||||
|
export * from './types/shop'
|
||||||
|
|||||||
53
packages/shared/src/types/shop.ts
Normal file
53
packages/shared/src/types/shop.ts
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
/** All money values are integer fen. Stock excludes pending reservations. */
|
||||||
|
export interface ShopProduct {
|
||||||
|
id: string
|
||||||
|
title: string
|
||||||
|
description: string
|
||||||
|
coverUrl: string
|
||||||
|
price: number
|
||||||
|
stock: number
|
||||||
|
sold: number
|
||||||
|
isActive: boolean
|
||||||
|
sortOrder: number
|
||||||
|
version: number
|
||||||
|
}
|
||||||
|
export type ShopOrderStatus = 'PENDING' | 'PAID' | 'COMPLETED' | 'CANCELLED'
|
||||||
|
export interface ShopOrder {
|
||||||
|
id: string
|
||||||
|
orderNo: string
|
||||||
|
productId: string
|
||||||
|
title: string
|
||||||
|
coverUrl: string
|
||||||
|
unitPrice: number
|
||||||
|
quantity: number
|
||||||
|
amount: number
|
||||||
|
contactName: string
|
||||||
|
contactPhone: string
|
||||||
|
status: ShopOrderStatus
|
||||||
|
pickupCode: string
|
||||||
|
expiresAt: string
|
||||||
|
createdAt: string
|
||||||
|
paidAt: string | null
|
||||||
|
completedAt: string | null
|
||||||
|
}
|
||||||
|
export interface ShopPage<T> {
|
||||||
|
items: T[]
|
||||||
|
total: number
|
||||||
|
page: number
|
||||||
|
limit: number
|
||||||
|
}
|
||||||
|
export interface ShopSummary {
|
||||||
|
revenue: number
|
||||||
|
quantity: number
|
||||||
|
orders: number
|
||||||
|
pendingPickup: number
|
||||||
|
activeProducts: number
|
||||||
|
lowStock: number
|
||||||
|
}
|
||||||
|
export interface ShopStockChange {
|
||||||
|
id: string
|
||||||
|
delta: number
|
||||||
|
reason: string
|
||||||
|
operatorId: string
|
||||||
|
createdAt: string
|
||||||
|
}
|
||||||
@@ -12,7 +12,7 @@ export interface StudioConfig {
|
|||||||
readonly updatedAt: string
|
readonly updatedAt: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export type StudioAssetType = 'gallery' | 'logo' | 'banner' | 'card-cover'
|
export type StudioAssetType = 'gallery' | 'logo' | 'banner' | 'product-cover' | 'card-cover'
|
||||||
|
|
||||||
export interface UpdateStudioConfigDto {
|
export interface UpdateStudioConfigDto {
|
||||||
readonly name?: string
|
readonly name?: string
|
||||||
|
|||||||
Reference in New Issue
Block a user