349 lines
9.3 KiB
TypeScript
349 lines
9.3 KiB
TypeScript
import type { Request, Response } from 'express';
|
||
|
||
type ProductStatus =
|
||
| 'draft'
|
||
| 'pending_review'
|
||
| 'online'
|
||
| 'offline'
|
||
| 'rejected';
|
||
|
||
type ProductItem = {
|
||
id: string;
|
||
name: string;
|
||
category: string;
|
||
sku: string;
|
||
originalPrice: number;
|
||
salePrice: number;
|
||
costPrice?: number;
|
||
stock: number;
|
||
safetyStock: number;
|
||
status: ProductStatus;
|
||
description?: string;
|
||
imageUrl?: string;
|
||
createdAt: string;
|
||
updatedAt: string;
|
||
};
|
||
|
||
type ProductBody = Partial<ProductItem>;
|
||
|
||
const PRODUCT_STATUS_LIST: ProductStatus[] = [
|
||
'draft',
|
||
'pending_review',
|
||
'online',
|
||
'offline',
|
||
'rejected',
|
||
];
|
||
|
||
const now = () => new Date().toISOString();
|
||
|
||
let productList: ProductItem[] = [
|
||
{
|
||
id: 'prod-1001',
|
||
name: '智能降噪耳机 Pro',
|
||
category: 'audio',
|
||
sku: 'AUDIO-001',
|
||
originalPrice: 1599,
|
||
salePrice: 1299,
|
||
costPrice: 760,
|
||
stock: 45,
|
||
safetyStock: 20,
|
||
status: 'online',
|
||
description: '旗舰主动降噪,支持空间音频。',
|
||
imageUrl:
|
||
'https://images.unsplash.com/photo-1505740420928-5e560c06d30e?q=80&w=200&auto=format&fit=crop',
|
||
createdAt: '2024-02-14T09:00:00.000Z',
|
||
updatedAt: '2024-02-14T09:00:00.000Z',
|
||
},
|
||
{
|
||
id: 'prod-1002',
|
||
name: '机械键盘无线版',
|
||
category: 'peripheral',
|
||
sku: 'KEYB-002',
|
||
originalPrice: 799,
|
||
salePrice: 599,
|
||
costPrice: 320,
|
||
stock: 0,
|
||
safetyStock: 10,
|
||
status: 'offline',
|
||
description: '三模连接,热插拔轴体。',
|
||
imageUrl:
|
||
'https://images.unsplash.com/photo-1511467687858-23d96c32e4ae?q=80&w=200&auto=format&fit=crop',
|
||
createdAt: '2024-02-14T10:30:00.000Z',
|
||
updatedAt: '2024-02-14T10:30:00.000Z',
|
||
},
|
||
{
|
||
id: 'prod-1003',
|
||
name: '4K 电竞显示器',
|
||
category: 'digital',
|
||
sku: 'DISP-003',
|
||
originalPrice: 3999,
|
||
salePrice: 3599,
|
||
costPrice: 2410,
|
||
stock: 8,
|
||
safetyStock: 12,
|
||
status: 'pending_review',
|
||
description: '144Hz 刷新率,HDR 1000。',
|
||
imageUrl:
|
||
'https://images.unsplash.com/photo-1527443224154-c4a3942d3acf?q=80&w=200&auto=format&fit=crop',
|
||
createdAt: '2024-02-14T11:15:00.000Z',
|
||
updatedAt: '2024-02-14T11:15:00.000Z',
|
||
},
|
||
{
|
||
id: 'prod-1004',
|
||
name: '便携投影仪 Lite',
|
||
category: 'digital',
|
||
sku: 'PROJ-004',
|
||
originalPrice: 2999,
|
||
salePrice: 2599,
|
||
stock: 22,
|
||
safetyStock: 8,
|
||
status: 'draft',
|
||
description: '1080P 分辨率,自动梯形校正。',
|
||
createdAt: '2024-02-15T08:20:00.000Z',
|
||
updatedAt: '2024-02-15T08:20:00.000Z',
|
||
},
|
||
{
|
||
id: 'prod-1005',
|
||
name: '智能手环 X2',
|
||
category: 'wearable',
|
||
sku: 'WEAR-005',
|
||
originalPrice: 399,
|
||
salePrice: 299,
|
||
costPrice: 120,
|
||
stock: 15,
|
||
safetyStock: 15,
|
||
status: 'rejected',
|
||
description: '支持心率、血氧与睡眠监测。',
|
||
createdAt: '2024-02-15T10:00:00.000Z',
|
||
updatedAt: '2024-02-15T10:00:00.000Z',
|
||
},
|
||
];
|
||
|
||
const coreFieldChanged = (
|
||
product: ProductItem,
|
||
patch: ProductBody,
|
||
): boolean => {
|
||
return (
|
||
(typeof patch.name === 'string' && patch.name !== product.name) ||
|
||
(typeof patch.sku === 'string' && patch.sku !== product.sku) ||
|
||
(typeof patch.originalPrice === 'number' &&
|
||
patch.originalPrice !== product.originalPrice) ||
|
||
(typeof patch.salePrice === 'number' &&
|
||
patch.salePrice !== product.salePrice)
|
||
);
|
||
};
|
||
|
||
const isProductStatus = (value: unknown): value is ProductStatus => {
|
||
return (
|
||
typeof value === 'string' &&
|
||
PRODUCT_STATUS_LIST.includes(value as ProductStatus)
|
||
);
|
||
};
|
||
|
||
const isPositiveNumber = (value: unknown): value is number => {
|
||
return typeof value === 'number' && value > 0;
|
||
};
|
||
|
||
const hasCreatePriceError = (payload: ProductBody): boolean => {
|
||
if (!isPositiveNumber(payload.originalPrice)) {
|
||
return true;
|
||
}
|
||
if (!isPositiveNumber(payload.salePrice)) {
|
||
return true;
|
||
}
|
||
|
||
return payload.salePrice > payload.originalPrice;
|
||
};
|
||
|
||
const hasPriceError = (payload: ProductBody): boolean => {
|
||
if (typeof payload.originalPrice === 'number' && payload.originalPrice <= 0) {
|
||
return true;
|
||
}
|
||
if (typeof payload.salePrice === 'number' && payload.salePrice <= 0) {
|
||
return true;
|
||
}
|
||
if (
|
||
typeof payload.originalPrice === 'number' &&
|
||
typeof payload.salePrice === 'number' &&
|
||
payload.salePrice > payload.originalPrice
|
||
) {
|
||
return true;
|
||
}
|
||
return false;
|
||
};
|
||
|
||
const parseString = (value: unknown): string | undefined => {
|
||
return typeof value === 'string' && value.trim() ? value.trim() : undefined;
|
||
};
|
||
|
||
export default {
|
||
'GET /api/products': (req: Request, res: Response) => {
|
||
const current = Number(req.query.current) || 1;
|
||
const pageSize = Number(req.query.pageSize) || 10;
|
||
const name = parseString(req.query.name);
|
||
const category = parseString(req.query.category);
|
||
const sku = parseString(req.query.sku);
|
||
const status = parseString(req.query.status) as ProductStatus | undefined;
|
||
const stockWarning = parseString(req.query.stockWarning);
|
||
|
||
let filtered = [...productList];
|
||
|
||
if (name) {
|
||
filtered = filtered.filter((item) => item.name.includes(name));
|
||
}
|
||
if (category) {
|
||
filtered = filtered.filter((item) => item.category === category);
|
||
}
|
||
if (sku) {
|
||
filtered = filtered.filter((item) => item.sku.includes(sku));
|
||
}
|
||
if (status) {
|
||
filtered = filtered.filter((item) => item.status === status);
|
||
}
|
||
if (stockWarning === 'warning') {
|
||
filtered = filtered.filter(
|
||
(item) => item.stock > 0 && item.stock < item.safetyStock,
|
||
);
|
||
}
|
||
if (stockWarning === 'empty') {
|
||
filtered = filtered.filter((item) => item.stock === 0);
|
||
}
|
||
|
||
const start = (current - 1) * pageSize;
|
||
const data = filtered.slice(start, start + pageSize);
|
||
|
||
res.json({
|
||
success: true,
|
||
data,
|
||
total: filtered.length,
|
||
});
|
||
},
|
||
|
||
'GET /api/products/:id': (req: Request, res: Response) => {
|
||
const { id } = req.params;
|
||
const item = productList.find((product) => product.id === id);
|
||
|
||
if (!item) {
|
||
res.status(404).json({ success: false, message: '商品不存在' });
|
||
return;
|
||
}
|
||
|
||
res.json(item);
|
||
},
|
||
|
||
'POST /api/products': (req: Request, res: Response) => {
|
||
const payload = req.body as ProductBody;
|
||
|
||
if (hasCreatePriceError(payload)) {
|
||
res.status(400).json({ success: false, message: '价格校验失败' });
|
||
return;
|
||
}
|
||
|
||
if (payload.status !== undefined && !isProductStatus(payload.status)) {
|
||
res.status(400).json({ success: false, message: '状态非法' });
|
||
return;
|
||
}
|
||
|
||
const created: ProductItem = {
|
||
id: `prod-${Date.now()}`,
|
||
name: String(payload.name || ''),
|
||
category: String(payload.category || 'digital'),
|
||
sku: String(payload.sku || ''),
|
||
originalPrice: Number(payload.originalPrice || 0),
|
||
salePrice: Number(payload.salePrice || 0),
|
||
costPrice:
|
||
typeof payload.costPrice === 'number'
|
||
? Number(payload.costPrice)
|
||
: undefined,
|
||
stock: Number(payload.stock || 0),
|
||
safetyStock: Number(payload.safetyStock || 0),
|
||
status: payload.status ?? 'draft',
|
||
description: payload.description,
|
||
imageUrl: payload.imageUrl,
|
||
createdAt: now(),
|
||
updatedAt: now(),
|
||
};
|
||
|
||
productList = [created, ...productList];
|
||
res.json(created);
|
||
},
|
||
|
||
'PUT /api/products/status': (req: Request, res: Response) => {
|
||
const payload = req.body as { ids?: string[]; status?: ProductStatus };
|
||
const ids = Array.isArray(payload.ids) ? payload.ids : [];
|
||
const status = payload.status;
|
||
|
||
if (!status || !['online', 'offline'].includes(status)) {
|
||
res.status(400).json({ success: false, message: '状态非法' });
|
||
return;
|
||
}
|
||
|
||
productList = productList.map((item) =>
|
||
ids.includes(item.id) ? { ...item, status, updatedAt: now() } : item,
|
||
);
|
||
|
||
res.json({ success: true });
|
||
},
|
||
|
||
'PUT /api/products/:id': (req: Request, res: Response) => {
|
||
const { id } = req.params;
|
||
const payload = req.body as ProductBody;
|
||
const index = productList.findIndex((product) => product.id === id);
|
||
|
||
if (index === -1) {
|
||
res.status(404).json({ success: false, message: '商品不存在' });
|
||
return;
|
||
}
|
||
|
||
const current = productList[index];
|
||
|
||
if (payload.status !== undefined && !isProductStatus(payload.status)) {
|
||
res.status(400).json({ success: false, message: '状态非法' });
|
||
return;
|
||
}
|
||
|
||
if (current.status === 'online' && coreFieldChanged(current, payload)) {
|
||
res.status(400).json({
|
||
success: false,
|
||
message: '已上架商品修改核心字段前需先下架',
|
||
});
|
||
return;
|
||
}
|
||
|
||
const merged = {
|
||
...current,
|
||
...payload,
|
||
updatedAt: now(),
|
||
} as ProductItem;
|
||
|
||
if (hasPriceError(merged)) {
|
||
res.status(400).json({ success: false, message: '价格校验失败' });
|
||
return;
|
||
}
|
||
|
||
productList[index] = merged;
|
||
res.json(productList[index]);
|
||
},
|
||
|
||
'DELETE /api/products/:id': (req: Request, res: Response) => {
|
||
const { id } = req.params;
|
||
const target = productList.find((item) => item.id === id);
|
||
|
||
if (!target) {
|
||
res.status(404).json({ success: false, message: '商品不存在' });
|
||
return;
|
||
}
|
||
|
||
if (!['draft', 'offline'].includes(target.status)) {
|
||
res.status(400).json({
|
||
success: false,
|
||
message: '仅草稿和已下架商品可删除',
|
||
});
|
||
return;
|
||
}
|
||
|
||
productList = productList.filter((item) => item.id !== id);
|
||
res.json({ success: true });
|
||
},
|
||
};
|