feat: 支持商品管理
This commit is contained in:
@@ -1,3 +1,4 @@
|
||||
import { ShopModule } from './shop/shop.module'
|
||||
import { Module } from '@nestjs/common'
|
||||
import { ConfigModule } from '@nestjs/config'
|
||||
import { AppController } from './app.controller'
|
||||
@@ -29,6 +30,7 @@ import { BodyPortraitModule } from './body-portrait/body-portrait.module'
|
||||
BookingModule,
|
||||
SchedulerModule,
|
||||
PaymentModule,
|
||||
ShopModule,
|
||||
AdminModule,
|
||||
InviteModule,
|
||||
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
|
||||
openid: string
|
||||
description: string
|
||||
expiresAt?: Date
|
||||
notifyUrl?: string
|
||||
}
|
||||
|
||||
export interface WxPaymentParams {
|
||||
@@ -95,7 +97,8 @@ export class WechatPayService {
|
||||
mchid: this.mchId,
|
||||
description: params.description,
|
||||
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: {
|
||||
total: Math.round(params.amount), // amount is already in fen (cents)
|
||||
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 ────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
@@ -267,10 +296,10 @@ export class WechatPayService {
|
||||
nonceStr: string,
|
||||
timestamp: string,
|
||||
): 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
|
||||
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`
|
||||
|
||||
// Sign with merchant's RSA private key using SHA256 with RSA
|
||||
@@ -281,6 +310,7 @@ export class WechatPayService {
|
||||
|
||||
const response = await fetch(url, {
|
||||
method,
|
||||
signal: AbortSignal.timeout(15000),
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'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
|
||||
|
||||
@IsOptional()
|
||||
@IsIn(['gallery', 'logo', 'banner', 'card-cover'])
|
||||
@IsIn(['gallery', 'logo', 'banner', 'product-cover', 'card-cover'])
|
||||
assetType?: StudioAssetType
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user