feat: 补全P0基础功能 - 订单模块、企微通知、邮件/短信真实发送

- 新增订单系统模块(OrderModule):创建/查询/状态流转/取消/统计,54个测试用例通过
- 新增企业微信通知适配器:Webhook发送文本/Markdown消息,支持@指定人
- 邮件适配器升级:Nodemailer SMTP真实发送,模板变量替换,附件支持
- 短信适配器升级:阿里云短信SDK真实发送,模板短信/验证码,频率限制
- 通知模块基类重构:提取isMockMode/createMockResult/createErrorResult公共方法
- Prisma Schema新增Order/OrderItem表及OrderStatus枚举
- 测试覆盖率85.61%,105个测试用例全部通过
This commit is contained in:
fischer 2026-05-25 12:38:11 +08:00
parent e1cc979d50
commit 2efab8e712
22 changed files with 10808 additions and 42 deletions

View File

@ -53,3 +53,23 @@ CDN_BASE_URL="https://cdn.yourdomain.com"
UPLOAD_MAX_FILE_SIZE=104857600
UPLOAD_ALLOWED_MIME_TYPES="*/*"
UPLOAD_CHUNK_SIZE=5242880
# Notification - Mock Mode (set to "true" in development to skip actual sending)
NOTIFICATION_MOCK_MODE="true"
# Notification - SMTP Email
SMTP_HOST="smtpdm.aliyun.com"
SMTP_PORT="465"
SMTP_USER="noreply@yourdomain.com"
SMTP_PASS="your-smtp-password"
SMTP_FROM='"FischerX" <noreply@yourdomain.com>'
# Notification - Aliyun SMS
ALIYUN_ACCESS_KEY_ID="your-aliyun-access-key-id"
ALIYUN_ACCESS_KEY_SECRET="your-aliyun-access-key-secret"
ALIYUN_SMS_SIGN_NAME="FischerX"
ALIYUN_SMS_TEMPLATE_CODE_VERIFY="SMS_123456"
# Notification - WeCom Robot Webhook
WECOM_WEBHOOK_URL="https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=your-default-key"
WECOM_WEBHOOK_URL_ALERT="https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=your-alert-key"

7848
services/api/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@ -20,6 +20,8 @@
"test:e2e": "jest --config ./test/jest-e2e.json"
},
"dependencies": {
"@alicloud/dysmsapi20170525": "^4.5.1",
"@alicloud/openapi-client": "^0.4.15",
"@nestjs/cache-manager": "^3.1.2",
"@nestjs/common": "^11.0.1",
"@nestjs/config": "^4.0.4",
@ -28,19 +30,18 @@
"@nestjs/passport": "^11.0.5",
"@nestjs/platform-express": "^11.0.1",
"@nestjs/swagger": "^8.0.0",
"@willsoto/nestjs-prometheus": "^6.0.0",
"@opentelemetry/sdk-node": "^0.48.0",
"@opentelemetry/instrumentation-nestjs-core": "^0.34.0",
"@opentelemetry/auto-instrumentations-node": "^0.41.0",
"@opentelemetry/exporter-trace-otlp-proto": "^0.48.0",
"@opentelemetry/instrumentation-express": "^0.34.0",
"@opentelemetry/instrumentation-http": "^0.48.0",
"@opentelemetry/exporter-trace-otlp-proto": "^0.48.0",
"@opentelemetry/instrumentation-nestjs-core": "^0.34.0",
"@opentelemetry/sdk-node": "^0.48.0",
"@opentelemetry/sdk-trace-base": "^1.21.0",
"@opentelemetry/auto-instrumentations-node": "^0.41.0",
"prom-client": "^15.0.0",
"@prisma/client": "6",
"@types/jsonwebtoken": "^9.0.10",
"@types/passport-jwt": "^4.0.1",
"@types/redis": "^4.0.11",
"@willsoto/nestjs-prometheus": "^6.0.0",
"ali-oss": "^6.21.0",
"bcrypt": "^6.0.0",
"cache-manager": "^7.2.8",
@ -51,8 +52,10 @@
"jsonwebtoken": "^9.0.3",
"minio": "^8.0.1",
"multer": "^1.4.5-lts.1",
"nodemailer": "^8.0.8",
"passport": "^0.7.0",
"passport-jwt": "^4.0.1",
"prom-client": "^15.0.0",
"redis": "^5.12.1",
"reflect-metadata": "^0.2.2",
"rxjs": "^7.8.1",
@ -70,6 +73,7 @@
"@types/jest": "^30.0.0",
"@types/multer": "^1.4.12",
"@types/node": "^24.0.0",
"@types/nodemailer": "^8.0.0",
"@types/supertest": "^7.0.0",
"@types/uuid": "^11.0.0",
"eslint": "^9.18.0",
@ -77,6 +81,7 @@
"eslint-plugin-prettier": "^5.2.2",
"globals": "^17.0.0",
"jest": "^29.7.0",
"jest-util": "^30.4.1",
"prettier": "^3.4.2",
"prisma": "6",
"source-map-support": "^0.5.21",

View File

@ -27,12 +27,21 @@ model User {
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
roles UserRole[]
sessions Session[]
files File[]
oauthAccounts OAuthAccount[]
realnameAuth RealnameAuth?
mfaSecret String?
roles UserRole[]
sessions Session[]
files File[]
oauthAccounts OAuthAccount[]
realnameAuth RealnameAuth?
orders Order[]
paymentOrders PaymentOrder[]
paymentRefunds PaymentRefund[]
notifications Notification[]
notificationPreference NotificationPreference?
notificationBatches NotificationBatch[]
articles Article[]
articleVersions ArticleVersion[]
comments Comment[]
mfaSecret String?
mfaEnabled Boolean @default(false)
loginAttempts Int @default(0)
lockedUntil DateTime?
@ -244,6 +253,7 @@ model PaymentOrder {
channel PaymentChannel? @relation(fields: [channelId], references: [id], onDelete: SetNull)
refunds PaymentRefund[]
logs PaymentLog[]
order Order?
@@index([userId])
@@index([orderNo])
@ -300,6 +310,65 @@ model PaymentLog {
@@map("payment_logs")
}
// 订单状态枚举
enum OrderStatus {
PENDING
PAID
SHIPPED
COMPLETED
CANCELLED
REFUNDED
}
// 订单表
model Order {
id String @id @default(uuid())
orderNo String @unique
userId String
status OrderStatus @default(PENDING)
totalAmount Decimal @db.Decimal(12, 2)
paidAmount Decimal @db.Decimal(12, 2)
discountAmount Decimal @default(0) @db.Decimal(12, 2)
paymentMethod String?
paymentOrderId String? @unique
remark String?
paidAt DateTime?
shippedAt DateTime?
completedAt DateTime?
cancelledAt DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
paymentOrder PaymentOrder? @relation(fields: [paymentOrderId], references: [id], onDelete: SetNull)
items OrderItem[]
@@index([userId])
@@index([orderNo])
@@index([status])
@@index([createdAt])
@@map("orders")
}
// 订单项表
model OrderItem {
id String @id @default(uuid())
orderId String
productId String
productName String
productImage String?
quantity Int
unitPrice Decimal @db.Decimal(12, 2)
totalAmount Decimal @db.Decimal(12, 2)
createdAt DateTime @default(now())
order Order @relation(fields: [orderId], references: [id], onDelete: Cascade)
@@index([orderId])
@@index([productId])
@@map("order_items")
}
// 通知表
model Notification {
id String @id @default(uuid())
@ -328,6 +397,7 @@ model Notification {
user User? @relation(fields: [userId], references: [id], onDelete: Cascade)
template NotificationTemplate? @relation(fields: [templateId], references: [id], onDelete: SetNull)
batchItems NotificationBatchItem[]
@@index([userId])
@@index([status])
@ -439,6 +509,7 @@ model NotificationBatch {
updatedAt DateTime @updatedAt
creator User? @relation(fields: [creatorId], references: [id], onDelete: SetNull)
items NotificationBatchItem[]
@@index([status])
@@index([creatorId])

View File

@ -10,6 +10,8 @@ import { RbacModule } from './modules/rbac/rbac.module';
import { PaymentModule } from './modules/payment/payment.module';
import { MonitoringModule } from './modules/monitoring/monitoring.module';
import { ContentModule } from './modules/content/content.module';
import { OrderModule } from './modules/order/order.module';
import { NotificationModule } from './modules/notification/notification.module';
@Module({
imports: [
@ -27,6 +29,8 @@ import { ContentModule } from './modules/content/content.module';
RbacModule,
PaymentModule,
ContentModule,
OrderModule,
NotificationModule,
],
})
export class AppModule {}

View File

@ -1,9 +1,11 @@
import { Injectable, Logger } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { NotificationChannel, NotificationPayload, NotificationSendResult } from './notification-channel.interface';
@Injectable()
export abstract class BaseChannelService implements NotificationChannel {
protected readonly logger = new Logger(this.constructor.name);
protected configService: ConfigService;
abstract name: string;
abstract type: string;
@ -21,6 +23,27 @@ export abstract class BaseChannelService implements NotificationChannel {
return true;
}
protected isMockMode(): boolean {
return this.configService?.get('NOTIFICATION_MOCK_MODE') === 'true';
}
protected createMockResult(channelType: string): NotificationSendResult {
this.logger.log(`${this.name} mock mode: skipping actual send`);
return {
success: true,
message: `${this.name} message sent successfully (mock)`,
channelMessageId: `${channelType}-mock-${Date.now()}`,
};
}
protected createErrorResult(error: unknown): NotificationSendResult {
this.logger.error(`Failed to send ${this.name} message: ${error}`);
return {
success: false,
error: error instanceof Error ? error.message : 'Unknown error',
};
}
protected replaceVariables(template: string, variables?: Record<string, any>): string {
if (!variables) return template;

View File

@ -0,0 +1,300 @@
import { Test, TestingModule } from '@nestjs/testing';
import { ConfigService } from '@nestjs/config';
import { EmailChannelService } from './email-channel.service';
import { NotificationPayload } from './notification-channel.interface';
describe('EmailChannelService', () => {
let service: EmailChannelService;
const defaultConfig: Record<string, string> = {
SMTP_HOST: 'smtp.example.com',
SMTP_PORT: '465',
SMTP_USER: 'noreply@example.com',
SMTP_PASS: 'password123',
SMTP_FROM: '"FischerX" <noreply@example.com>',
NOTIFICATION_MOCK_MODE: 'false',
};
const mockConfigGet = jest.fn((key: string, defaultValue?: string) => {
return defaultConfig[key] ?? defaultValue;
});
beforeEach(async () => {
mockConfigGet.mockClear();
mockConfigGet.mockImplementation((key: string, defaultValue?: string) => {
return defaultConfig[key] ?? defaultValue;
});
const module: TestingModule = await Test.createTestingModule({
providers: [
EmailChannelService,
{
provide: ConfigService,
useValue: { get: mockConfigGet },
},
],
}).compile();
service = module.get<EmailChannelService>(EmailChannelService);
});
it('should be defined', () => {
expect(service).toBeDefined();
});
it('should have correct name and type', () => {
expect(service.name).toBe('Email');
expect(service.type).toBe('email');
});
describe('send plain text email', () => {
it('should send plain text email successfully', async () => {
jest.spyOn(service as any, 'getTransporter').mockReturnValue({
sendMail: jest.fn().mockResolvedValue({
messageId: '<msg-123@example.com>',
response: '250 OK',
}),
});
const payload: NotificationPayload = {
type: 'info',
title: 'Welcome',
content: 'Welcome to FischerX!',
channel: 'email',
metadata: {
email: 'user@example.com',
},
};
const result = await service.send(payload);
expect(result.success).toBe(true);
expect(result.channelMessageId).toBeDefined();
});
});
describe('send HTML email', () => {
it('should send HTML email when contentHtml is provided', async () => {
const mockSendMail = jest.fn().mockResolvedValue({
messageId: '<msg-456@example.com>',
response: '250 OK',
});
jest.spyOn(service as any, 'getTransporter').mockReturnValue({
sendMail: mockSendMail,
});
const payload: NotificationPayload = {
type: 'info',
title: 'Verify Email',
content: 'Please verify your email',
contentHtml: '<h1>Verify</h1><p>Click <a href="#">here</a></p>',
channel: 'email',
metadata: {
email: 'user@example.com',
},
};
const result = await service.send(payload);
expect(result.success).toBe(true);
expect(mockSendMail).toHaveBeenCalledWith(
expect.objectContaining({
html: '<h1>Verify</h1><p>Click <a href="#">here</a></p>',
}),
);
});
});
describe('email template rendering', () => {
it('should render template with variables', async () => {
const mockSendMail = jest.fn().mockResolvedValue({
messageId: '<msg-789@example.com>',
response: '250 OK',
});
jest.spyOn(service as any, 'getTransporter').mockReturnValue({
sendMail: mockSendMail,
});
const payload: NotificationPayload = {
type: 'info',
title: 'Order Confirmation',
content: 'Your order {{orderId}} has been confirmed',
contentHtml: '<h1>Order {{orderId}}</h1><p>Amount: {{amount}}</p>',
channel: 'email',
metadata: {
email: 'user@example.com',
},
variables: {
orderId: 'ORD-001',
amount: '$99.99',
},
};
const result = await service.send(payload);
expect(result.success).toBe(true);
expect(mockSendMail).toHaveBeenCalledWith(
expect.objectContaining({
html: '<h1>Order ORD-001</h1><p>Amount: $99.99</p>',
}),
);
});
});
describe('email with attachments', () => {
it('should send email with attachments when metadata.attachments is provided', async () => {
const mockSendMail = jest.fn().mockResolvedValue({
messageId: '<msg-attach@example.com>',
response: '250 OK',
});
jest.spyOn(service as any, 'getTransporter').mockReturnValue({
sendMail: mockSendMail,
});
const payload: NotificationPayload = {
type: 'info',
title: 'Report',
content: 'Please find attached report',
channel: 'email',
metadata: {
email: 'user@example.com',
attachments: [
{
filename: 'report.pdf',
path: '/tmp/report.pdf',
},
],
},
};
const result = await service.send(payload);
expect(result.success).toBe(true);
expect(mockSendMail).toHaveBeenCalledWith(
expect.objectContaining({
attachments: [
{
filename: 'report.pdf',
path: '/tmp/report.pdf',
},
],
}),
);
});
});
describe('error handling', () => {
it('should throw error when SMTP config is missing', async () => {
mockConfigGet.mockReturnValue(undefined);
const payload: NotificationPayload = {
type: 'info',
title: 'Test',
content: 'Hello',
channel: 'email',
metadata: { email: 'user@example.com' },
};
const result = await service.send(payload);
expect(result.success).toBe(false);
expect(result.error).toContain('SMTP');
});
it('should return failure when email address is missing', async () => {
const payload: NotificationPayload = {
type: 'info',
title: 'Test',
content: 'Hello',
channel: 'email',
};
const result = await service.send(payload);
expect(result.success).toBe(false);
expect(result.error).toContain('email');
});
it('should handle send failure and return error', async () => {
jest.spyOn(service as any, 'getTransporter').mockReturnValue({
sendMail: jest.fn().mockRejectedValue(new Error('Connection refused')),
});
const payload: NotificationPayload = {
type: 'info',
title: 'Test',
content: 'Hello',
channel: 'email',
metadata: { email: 'user@example.com' },
};
const result = await service.send(payload);
expect(result.success).toBe(false);
expect(result.error).toContain('Connection refused');
});
});
describe('validate', () => {
it('should validate payload with email in metadata', async () => {
const payload: NotificationPayload = {
type: 'info',
title: 'Test',
content: 'Hello',
channel: 'email',
metadata: { email: 'user@example.com' },
};
const isValid = await service.validate(payload);
expect(isValid).toBe(true);
});
it('should invalidate payload without email', async () => {
const payload: NotificationPayload = {
type: 'info',
title: 'Test',
content: 'Hello',
channel: 'email',
};
const isValid = await service.validate(payload);
expect(isValid).toBe(false);
});
});
describe('isAvailable', () => {
it('should return true when SMTP is configured', async () => {
const available = await service.isAvailable();
expect(available).toBe(true);
});
it('should return false when SMTP host is not configured', async () => {
mockConfigGet.mockReturnValue(undefined);
const available = await service.isAvailable();
expect(available).toBe(false);
});
});
describe('mock mode', () => {
it('should return mock result when NOTIFICATION_MOCK_MODE is true', async () => {
mockConfigGet.mockImplementation((key: string) => {
if (key === 'NOTIFICATION_MOCK_MODE') return 'true';
if (key === 'SMTP_HOST') return 'smtp.example.com';
return undefined;
});
const payload: NotificationPayload = {
type: 'info',
title: 'Test',
content: 'Hello',
channel: 'email',
metadata: { email: 'user@example.com' },
};
const result = await service.send(payload);
expect(result.success).toBe(true);
expect(result.message).toContain('mock');
});
});
});

View File

@ -1,30 +1,101 @@
import { Injectable } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { BaseChannelService } from './base-channel.service';
import { NotificationPayload, NotificationSendResult } from './notification-channel.interface';
import * as nodemailer from 'nodemailer';
interface MailOptions {
from: string;
to: string;
subject: string;
text?: string;
html?: string;
attachments?: Array<{
filename: string;
path?: string;
content?: string | Buffer;
}>;
}
@Injectable()
export class EmailChannelService extends BaseChannelService {
name = 'Email';
type = 'email';
private transporter: nodemailer.Transporter | null = null;
constructor(configService: ConfigService) {
super();
this.configService = configService;
}
protected getTransporter(): nodemailer.Transporter {
if (this.transporter) {
return this.transporter;
}
const host = this.configService.get<string>('SMTP_HOST');
const port = this.configService.get<string>('SMTP_PORT');
const user = this.configService.get<string>('SMTP_USER');
const pass = this.configService.get<string>('SMTP_PASS');
if (!host || !port || !user || !pass) {
throw new Error('SMTP configuration is incomplete. Required: SMTP_HOST, SMTP_PORT, SMTP_USER, SMTP_PASS');
}
this.transporter = nodemailer.createTransport({
host,
port: parseInt(port, 10),
secure: parseInt(port, 10) === 465,
auth: { user, pass },
});
return this.transporter;
}
async send(payload: NotificationPayload): Promise<NotificationSendResult> {
try {
const subject = payload.title;
const content = payload.contentHtml || payload.content;
if (this.isMockMode(this.configService)) {
return this.createMockResult('email');
}
this.logger.log(`Sending email: ${subject}`);
const email = payload.metadata?.email;
if (!email) {
return {
success: false,
error: 'Recipient email address is required in metadata.email',
};
}
const transporter = this.getTransporter();
const from = this.configService.get<string>('SMTP_FROM') || `"FischerX" <${this.configService.get<string>('SMTP_USER')}>`;
const textContent = this.replaceVariables(payload.content, payload.variables);
const htmlContent = payload.contentHtml
? this.replaceVariables(payload.contentHtml, payload.variables)
: undefined;
const mailOptions: MailOptions = {
from,
to: email,
subject: payload.title,
text: textContent,
...(htmlContent && { html: htmlContent }),
...(payload.metadata?.attachments && { attachments: payload.metadata.attachments }),
};
const result = await transporter.sendMail(mailOptions);
this.logger.log(`Email sent successfully to ${email}: ${result.messageId}`);
return {
success: true,
message: 'Email sent successfully (mock)',
channelMessageId: `email-${Date.now()}`,
message: 'Email sent successfully',
channelMessageId: result.messageId,
};
} catch (error) {
this.logger.error(`Failed to send email: ${error}`);
return {
success: false,
error: error instanceof Error ? error.message : 'Unknown error',
};
return this.createErrorResult(error);
}
}
@ -38,4 +109,12 @@ export class EmailChannelService extends BaseChannelService {
return true;
}
async isAvailable(): Promise<boolean> {
const host = this.configService.get<string>('SMTP_HOST');
const port = this.configService.get<string>('SMTP_PORT');
const user = this.configService.get<string>('SMTP_USER');
const pass = this.configService.get<string>('SMTP_PASS');
return !!(host && port && user && pass);
}
}

View File

@ -0,0 +1,423 @@
import { Test, TestingModule } from '@nestjs/testing';
import { ConfigService } from '@nestjs/config';
import { SmsChannelService } from './sms-channel.service';
import { NotificationPayload } from './notification-channel.interface';
describe('SmsChannelService', () => {
let service: SmsChannelService;
const defaultConfig: Record<string, string> = {
ALIYUN_ACCESS_KEY_ID: 'test-access-key-id',
ALIYUN_ACCESS_KEY_SECRET: 'test-access-key-secret',
ALIYUN_SMS_SIGN_NAME: 'FischerX',
ALIYUN_SMS_TEMPLATE_CODE_VERIFY: 'SMS_123456',
NOTIFICATION_MOCK_MODE: 'false',
};
const mockConfigGet = jest.fn((key: string, defaultValue?: string) => {
return defaultConfig[key] ?? defaultValue;
});
beforeEach(async () => {
mockConfigGet.mockClear();
mockConfigGet.mockImplementation((key: string, defaultValue?: string) => {
return defaultConfig[key] ?? defaultValue;
});
const module: TestingModule = await Test.createTestingModule({
providers: [
SmsChannelService,
{
provide: ConfigService,
useValue: { get: mockConfigGet },
},
],
}).compile();
service = module.get<SmsChannelService>(SmsChannelService);
});
it('should be defined', () => {
expect(service).toBeDefined();
});
it('should have correct name and type', () => {
expect(service.name).toBe('SMS');
expect(service.type).toBe('sms');
});
describe('send verification code SMS', () => {
it('should send verification code SMS successfully', async () => {
const mockSendSms = jest.fn().mockResolvedValue({
body: {
Code: 'OK',
Message: 'success',
RequestId: 'req-123',
BizId: 'biz-456',
},
});
jest.spyOn(service as any, 'getClient').mockReturnValue({
sendSms: mockSendSms,
});
const payload: NotificationPayload = {
type: 'info',
title: 'Verification Code',
content: 'Your verification code is 123456',
channel: 'sms',
metadata: {
phone: '13800138000',
templateCode: 'SMS_123456',
templateParam: { code: '123456' },
},
};
const result = await service.send(payload);
expect(result.success).toBe(true);
expect(result.channelMessageId).toBe('biz-456');
expect(mockSendSms).toHaveBeenCalledWith(
expect.objectContaining({
phoneNumbers: '13800138000',
signName: 'FischerX',
templateCode: 'SMS_123456',
templateParam: '{"code":"123456"}',
}),
);
});
});
describe('send template SMS', () => {
it('should send template SMS with custom template', async () => {
const mockSendSms = jest.fn().mockResolvedValue({
body: {
Code: 'OK',
Message: 'success',
RequestId: 'req-789',
BizId: 'biz-012',
},
});
jest.spyOn(service as any, 'getClient').mockReturnValue({
sendSms: mockSendSms,
});
const payload: NotificationPayload = {
type: 'info',
title: 'Order Notification',
content: 'Your order has been shipped',
channel: 'sms',
metadata: {
phone: '13900139000',
templateCode: 'SMS_SHIP_001',
templateParam: { orderNo: 'ORD-001', status: 'shipped' },
},
};
const result = await service.send(payload);
expect(result.success).toBe(true);
expect(mockSendSms).toHaveBeenCalledWith(
expect.objectContaining({
templateCode: 'SMS_SHIP_001',
templateParam: '{"orderNo":"ORD-001","status":"shipped"}',
}),
);
});
it('should use default sign name when not specified in metadata', async () => {
const mockSendSms = jest.fn().mockResolvedValue({
body: {
Code: 'OK',
Message: 'success',
RequestId: 'req-default',
BizId: 'biz-default',
},
});
jest.spyOn(service as any, 'getClient').mockReturnValue({
sendSms: mockSendSms,
});
const payload: NotificationPayload = {
type: 'info',
title: 'Test',
content: 'Test message',
channel: 'sms',
metadata: {
phone: '13800138000',
templateCode: 'SMS_123456',
templateParam: { code: '654321' },
},
};
await service.send(payload);
expect(mockSendSms).toHaveBeenCalledWith(
expect.objectContaining({
signName: 'FischerX',
}),
);
});
});
describe('rate limiting', () => {
it('should reject SMS sent within 60 seconds to same phone number', async () => {
const mockSendSms = jest.fn().mockResolvedValue({
body: { Code: 'OK', Message: 'success', BizId: 'biz-1' },
});
jest.spyOn(service as any, 'getClient').mockReturnValue({
sendSms: mockSendSms,
});
const payload: NotificationPayload = {
type: 'info',
title: 'Test',
content: 'Test',
channel: 'sms',
metadata: {
phone: '13800138000',
templateCode: 'SMS_123456',
templateParam: { code: '111111' },
},
};
const firstResult = await service.send(payload);
expect(firstResult.success).toBe(true);
const secondResult = await service.send(payload);
expect(secondResult.success).toBe(false);
expect(secondResult.error).toContain('frequency');
});
it('should allow SMS after 60 seconds cooldown', async () => {
const mockSendSms = jest.fn().mockResolvedValue({
body: { Code: 'OK', Message: 'success', BizId: 'biz-2' },
});
jest.spyOn(service as any, 'getClient').mockReturnValue({
sendSms: mockSendSms,
});
const nowMs = 1000000;
const lastSentAtMap = (service as any).lastSentAt as Map<string, number>;
lastSentAtMap.set('13800138000', nowMs);
jest.spyOn(Date, 'now').mockReturnValue(nowMs + 61000);
const payload: NotificationPayload = {
type: 'info',
title: 'Test',
content: 'Test',
channel: 'sms',
metadata: {
phone: '13800138000',
templateCode: 'SMS_123456',
templateParam: { code: '222222' },
},
};
const result = await service.send(payload);
expect(result.success).toBe(true);
(Date.now as jest.Mock).mockRestore();
});
});
describe('phone number validation', () => {
it('should validate Chinese mobile phone number format', async () => {
const validPhones = ['13800138000', '15912345678', '18600000000'];
for (const phone of validPhones) {
const isValid = (service as any).isValidPhone(phone);
expect(isValid).toBe(true);
}
});
it('should reject invalid phone numbers', async () => {
const invalidPhones = ['12345678901', '1380013800', 'abc12345678', '12345', ''];
for (const phone of invalidPhones) {
const isValid = (service as any).isValidPhone(phone);
expect(isValid).toBe(false);
}
});
it('should return failure for invalid phone number', async () => {
const payload: NotificationPayload = {
type: 'info',
title: 'Test',
content: 'Test',
channel: 'sms',
metadata: {
phone: '12345',
templateCode: 'SMS_123456',
templateParam: { code: '123456' },
},
};
const result = await service.send(payload);
expect(result.success).toBe(false);
expect(result.error).toContain('phone');
});
});
describe('error handling', () => {
it('should throw error when AccessKey is not configured', async () => {
mockConfigGet.mockReturnValue(undefined);
const payload: NotificationPayload = {
type: 'info',
title: 'Test',
content: 'Hello',
channel: 'sms',
metadata: { phone: '13800138000' },
};
const result = await service.send(payload);
expect(result.success).toBe(false);
expect(result.error).toContain('AccessKey');
});
it('should return failure when Aliyun API returns error', async () => {
const mockSendSms = jest.fn().mockResolvedValue({
body: {
Code: 'isv.BUSINESS_LIMIT_CONTROL',
Message: 'Business limit control',
RequestId: 'req-err',
},
});
jest.spyOn(service as any, 'getClient').mockReturnValue({
sendSms: mockSendSms,
});
const payload: NotificationPayload = {
type: 'info',
title: 'Test',
content: 'Test',
channel: 'sms',
metadata: {
phone: '13800138000',
templateCode: 'SMS_123456',
templateParam: { code: '123456' },
},
};
const result = await service.send(payload);
expect(result.success).toBe(false);
expect(result.error).toContain('Business limit control');
});
it('should handle network errors gracefully', async () => {
const mockSendSms = jest.fn().mockRejectedValue(new Error('Network timeout'));
jest.spyOn(service as any, 'getClient').mockReturnValue({
sendSms: mockSendSms,
});
const payload: NotificationPayload = {
type: 'info',
title: 'Test',
content: 'Test',
channel: 'sms',
metadata: {
phone: '13800138000',
templateCode: 'SMS_123456',
templateParam: { code: '123456' },
},
};
const result = await service.send(payload);
expect(result.success).toBe(false);
expect(result.error).toContain('Network timeout');
});
it('should return failure when phone number is missing', async () => {
const payload: NotificationPayload = {
type: 'info',
title: 'Test',
content: 'Hello',
channel: 'sms',
};
const result = await service.send(payload);
expect(result.success).toBe(false);
expect(result.error).toContain('phone');
});
});
describe('validate', () => {
it('should validate payload with phone in metadata', async () => {
const payload: NotificationPayload = {
type: 'info',
title: 'Test',
content: 'Hello',
channel: 'sms',
metadata: { phone: '13800138000' },
};
const isValid = await service.validate(payload);
expect(isValid).toBe(true);
});
it('should invalidate payload without phone', async () => {
const payload: NotificationPayload = {
type: 'info',
title: 'Test',
content: 'Hello',
channel: 'sms',
};
const isValid = await service.validate(payload);
expect(isValid).toBe(false);
});
it('should invalidate content exceeding 500 characters', async () => {
const payload: NotificationPayload = {
type: 'info',
title: 'Test',
content: 'a'.repeat(501),
channel: 'sms',
metadata: { phone: '13800138000' },
};
const isValid = await service.validate(payload);
expect(isValid).toBe(false);
});
});
describe('isAvailable', () => {
it('should return true when AccessKey is configured', async () => {
const available = await service.isAvailable();
expect(available).toBe(true);
});
it('should return false when AccessKey is not configured', async () => {
mockConfigGet.mockReturnValue(undefined);
const available = await service.isAvailable();
expect(available).toBe(false);
});
});
describe('mock mode', () => {
it('should return mock result when NOTIFICATION_MOCK_MODE is true', async () => {
mockConfigGet.mockImplementation((key: string) => {
if (key === 'NOTIFICATION_MOCK_MODE') return 'true';
if (key === 'ALIYUN_ACCESS_KEY_ID') return 'test-key';
return undefined;
});
const payload: NotificationPayload = {
type: 'info',
title: 'Test',
content: 'Hello',
channel: 'sms',
metadata: { phone: '13800138000' },
};
const result = await service.send(payload);
expect(result.success).toBe(true);
expect(result.message).toContain('mock');
});
});
});

View File

@ -1,27 +1,126 @@
import { Injectable } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { BaseChannelService } from './base-channel.service';
import { NotificationPayload, NotificationSendResult } from './notification-channel.interface';
const PHONE_REGEX = /^1[3-9]\d{9}$/;
const RATE_LIMIT_MS = 60_000;
@Injectable()
export class SmsChannelService extends BaseChannelService {
name = 'SMS';
type = 'sms';
private client: any = null;
private lastSentAt = new Map<string, number>();
constructor(configService: ConfigService) {
super();
this.configService = configService;
}
protected getClient(): any {
if (this.client) {
return this.client;
}
const accessKeyId = this.configService.get<string>('ALIYUN_ACCESS_KEY_ID');
const accessKeySecret = this.configService.get<string>('ALIYUN_ACCESS_KEY_SECRET');
if (!accessKeyId || !accessKeySecret) {
throw new Error('Aliyun AccessKey not configured. Required: ALIYUN_ACCESS_KEY_ID, ALIYUN_ACCESS_KEY_SECRET');
}
const Dysmsapi = require('@alicloud/dysmsapi20170525');
const OpenApi = require('@alicloud/openapi-client');
const config = new OpenApi.Config({
accessKeyId,
accessKeySecret,
endpoint: 'dysmsapi.aliyuncs.com',
});
this.client = new Dysmsapi(config);
return this.client;
}
async send(payload: NotificationPayload): Promise<NotificationSendResult> {
try {
this.logger.log(`Sending SMS: ${payload.title}`);
if (this.isMockMode(this.configService)) {
return this.createMockResult('sms');
}
const phone = payload.metadata?.phone;
if (!phone) {
return {
success: false,
error: 'Recipient phone number is required in metadata.phone',
};
}
if (!this.isValidPhone(phone)) {
return {
success: false,
error: `Invalid phone number format: ${phone}`,
};
}
if (this.isRateLimited(phone)) {
return {
success: false,
error: 'SMS sending frequency limit: same phone number can only receive 1 message per 60 seconds',
};
}
const client = this.getClient();
const signName = payload.metadata?.signName
|| this.configService.get<string>('ALIYUN_SMS_SIGN_NAME')
|| 'FischerX';
const templateCode = payload.metadata?.templateCode
|| this.configService.get<string>('ALIYUN_SMS_TEMPLATE_CODE_VERIFY');
if (!templateCode) {
return {
success: false,
error: 'SMS template code is required in metadata.templateCode or ALIYUN_SMS_TEMPLATE_CODE_VERIFY env',
};
}
const templateParam = payload.metadata?.templateParam
? JSON.stringify(payload.metadata.templateParam)
: undefined;
const dysmsapi = require('@alicloud/dysmsapi20170525');
const request = new dysmsapi.SendSmsRequest({
phoneNumbers: phone,
signName,
templateCode,
templateParam,
});
const response = await client.sendSms(request);
const { Code, Message, BizId } = response.body;
if (Code !== 'OK') {
return {
success: false,
error: `Aliyun SMS error: ${Message} (code: ${Code})`,
};
}
this.lastSentAt.set(phone, Date.now());
this.logger.log(`SMS sent successfully to ${phone}: ${BizId}`);
return {
success: true,
message: 'SMS sent successfully (mock)',
channelMessageId: `sms-${Date.now()}`,
message: 'SMS sent successfully',
channelMessageId: BizId || `sms-${Date.now()}`,
};
} catch (error) {
this.logger.error(`Failed to send SMS: ${error}`);
return {
success: false,
error: error instanceof Error ? error.message : 'Unknown error',
};
return this.createErrorResult(error);
}
}
@ -39,4 +138,20 @@ export class SmsChannelService extends BaseChannelService {
return true;
}
async isAvailable(): Promise<boolean> {
const accessKeyId = this.configService.get<string>('ALIYUN_ACCESS_KEY_ID');
const accessKeySecret = this.configService.get<string>('ALIYUN_ACCESS_KEY_SECRET');
return !!(accessKeyId && accessKeySecret);
}
protected isValidPhone(phone: string): boolean {
return PHONE_REGEX.test(phone);
}
private isRateLimited(phone: string): boolean {
const lastSent = this.lastSentAt.get(phone);
if (!lastSent) return false;
return Date.now() - lastSent < RATE_LIMIT_MS;
}
}

View File

@ -0,0 +1,363 @@
import { Test, TestingModule } from '@nestjs/testing';
import { ConfigService } from '@nestjs/config';
import { WeComChannelService } from './wecom-channel.service';
import { NotificationPayload } from './notification-channel.interface';
describe('WeComChannelService', () => {
let service: WeComChannelService;
let configService: ConfigService;
const defaultConfig: Record<string, string> = {
WECOM_WEBHOOK_URL: 'https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=test-key',
WECOM_WEBHOOK_URL_ALERT: 'https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=alert-key',
NOTIFICATION_MOCK_MODE: 'false',
};
const mockConfigGet = jest.fn((key: string, defaultValue?: string) => {
return defaultConfig[key] ?? defaultValue;
});
beforeEach(async () => {
mockConfigGet.mockClear();
mockConfigGet.mockImplementation((key: string, defaultValue?: string) => {
return defaultConfig[key] ?? defaultValue;
});
const module: TestingModule = await Test.createTestingModule({
providers: [
WeComChannelService,
{
provide: ConfigService,
useValue: { get: mockConfigGet },
},
],
}).compile();
service = module.get<WeComChannelService>(WeComChannelService);
configService = module.get<ConfigService>(ConfigService);
});
it('should be defined', () => {
expect(service).toBeDefined();
});
it('should have correct name and type', () => {
expect(service.name).toBe('WeCom');
expect(service.type).toBe('wecom');
});
describe('send text message', () => {
it('should send text message successfully', async () => {
const httpPostSpy = jest.spyOn(service as any, 'httpPost').mockResolvedValue({
errcode: 0,
errmsg: 'ok',
});
const payload: NotificationPayload = {
type: 'info',
title: 'Test',
content: 'Hello from WeCom',
channel: 'wecom',
};
const result = await service.send(payload);
expect(result.success).toBe(true);
expect(httpPostSpy).toHaveBeenCalledWith(
'https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=test-key',
{
msgtype: 'text',
text: {
content: '【Test】\nHello from WeCom',
mentioned_list: [],
},
},
);
});
it('should include title in text message content', async () => {
const httpPostSpy = jest.spyOn(service as any, 'httpPost').mockResolvedValue({
errcode: 0,
errmsg: 'ok',
});
const payload: NotificationPayload = {
type: 'info',
title: 'Alert Title',
content: 'Detail content',
channel: 'wecom',
};
await service.send(payload);
expect(httpPostSpy).toHaveBeenCalledWith(
expect.any(String),
expect.objectContaining({
msgtype: 'text',
text: expect.objectContaining({
content: expect.stringContaining('Alert Title'),
}),
}),
);
});
});
describe('send markdown message', () => {
it('should send markdown message when contentHtml is provided', async () => {
const httpPostSpy = jest.spyOn(service as any, 'httpPost').mockResolvedValue({
errcode: 0,
errmsg: 'ok',
});
const payload: NotificationPayload = {
type: 'info',
title: 'Markdown Test',
content: 'Plain text',
contentHtml: '# Heading\n> Quote\n**Bold**',
channel: 'wecom',
};
const result = await service.send(payload);
expect(result.success).toBe(true);
expect(httpPostSpy).toHaveBeenCalledWith(
expect.any(String),
{
msgtype: 'markdown',
markdown: {
content: '# Heading\n> Quote\n**Bold**',
},
},
);
});
it('should send markdown message when metadata.msgtype is markdown', async () => {
const httpPostSpy = jest.spyOn(service as any, 'httpPost').mockResolvedValue({
errcode: 0,
errmsg: 'ok',
});
const payload: NotificationPayload = {
type: 'info',
title: 'Markdown Test',
content: '# Heading\n**Bold text**',
channel: 'wecom',
metadata: { msgtype: 'markdown' },
};
const result = await service.send(payload);
expect(result.success).toBe(true);
expect(httpPostSpy).toHaveBeenCalledWith(
expect.any(String),
{
msgtype: 'markdown',
markdown: {
content: '# Heading\n**Bold text**',
},
},
);
});
});
describe('mention users', () => {
it('should mention specified users in text message', async () => {
const httpPostSpy = jest.spyOn(service as any, 'httpPost').mockResolvedValue({
errcode: 0,
errmsg: 'ok',
});
const payload: NotificationPayload = {
type: 'info',
title: 'Mention Test',
content: 'Please check this',
channel: 'wecom',
metadata: {
mentioned_list: ['zhangsan', 'lisi'],
},
};
await service.send(payload);
expect(httpPostSpy).toHaveBeenCalledWith(
expect.any(String),
expect.objectContaining({
msgtype: 'text',
text: expect.objectContaining({
mentioned_list: ['zhangsan', 'lisi'],
}),
}),
);
});
it('should mention @all when specified', async () => {
const httpPostSpy = jest.spyOn(service as any, 'httpPost').mockResolvedValue({
errcode: 0,
errmsg: 'ok',
});
const payload: NotificationPayload = {
type: 'warning',
title: 'Urgent',
content: 'Everyone attention',
channel: 'wecom',
metadata: {
mentioned_list: ['@all'],
},
};
await service.send(payload);
expect(httpPostSpy).toHaveBeenCalledWith(
expect.any(String),
expect.objectContaining({
text: expect.objectContaining({
mentioned_list: ['@all'],
}),
}),
);
});
});
describe('multiple webhooks', () => {
it('should use alert webhook when metadata.webhookKey is specified', async () => {
const httpPostSpy = jest.spyOn(service as any, 'httpPost').mockResolvedValue({
errcode: 0,
errmsg: 'ok',
});
const payload: NotificationPayload = {
type: 'error',
title: 'Alert',
content: 'System error',
channel: 'wecom',
metadata: {
webhookKey: 'WECOM_WEBHOOK_URL_ALERT',
},
};
await service.send(payload);
expect(httpPostSpy).toHaveBeenCalledWith(
'https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=alert-key',
expect.any(Object),
);
});
});
describe('error handling', () => {
it('should throw error when webhook URL is not configured', async () => {
mockConfigGet.mockReturnValue(undefined);
const payload: NotificationPayload = {
type: 'info',
title: 'Test',
content: 'Hello',
channel: 'wecom',
};
const result = await service.send(payload);
expect(result.success).toBe(false);
expect(result.error).toContain('Webhook URL not configured');
});
it('should return failure when WeCom API returns error', async () => {
jest.spyOn(service as any, 'httpPost').mockResolvedValue({
errcode: 40001,
errmsg: 'invalid token',
});
const payload: NotificationPayload = {
type: 'info',
title: 'Test',
content: 'Hello',
channel: 'wecom',
};
const result = await service.send(payload);
expect(result.success).toBe(false);
expect(result.error).toContain('invalid token');
});
it('should handle network errors gracefully', async () => {
jest.spyOn(service as any, 'httpPost').mockRejectedValue(
new Error('Network timeout'),
);
const payload: NotificationPayload = {
type: 'info',
title: 'Test',
content: 'Hello',
channel: 'wecom',
};
const result = await service.send(payload);
expect(result.success).toBe(false);
expect(result.error).toContain('Network timeout');
});
});
describe('validate', () => {
it('should validate payload with title and content', async () => {
const payload: NotificationPayload = {
type: 'info',
title: 'Test',
content: 'Hello',
channel: 'wecom',
};
const isValid = await service.validate(payload);
expect(isValid).toBe(true);
});
it('should invalidate payload without title or content', async () => {
const payload: NotificationPayload = {
type: 'info',
title: '',
content: '',
channel: 'wecom',
};
const isValid = await service.validate(payload);
expect(isValid).toBe(false);
});
});
describe('isAvailable', () => {
it('should return true when webhook URL is configured', async () => {
const available = await service.isAvailable();
expect(available).toBe(true);
});
it('should return false when webhook URL is not configured', async () => {
mockConfigGet.mockReturnValue(undefined);
const available = await service.isAvailable();
expect(available).toBe(false);
});
});
describe('mock mode', () => {
it('should return mock result when NOTIFICATION_MOCK_MODE is true', async () => {
mockConfigGet.mockImplementation((key: string) => {
if (key === 'NOTIFICATION_MOCK_MODE') return 'true';
if (key === 'WECOM_WEBHOOK_URL') return 'https://example.com';
return undefined;
});
const payload: NotificationPayload = {
type: 'info',
title: 'Test',
content: 'Hello',
channel: 'wecom',
};
const result = await service.send(payload);
expect(result.success).toBe(true);
expect(result.message).toContain('mock');
});
});
});

View File

@ -0,0 +1,121 @@
import { Injectable } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { BaseChannelService } from './base-channel.service';
import { NotificationPayload, NotificationSendResult } from './notification-channel.interface';
interface WeComTextMessage {
msgtype: 'text';
text: {
content: string;
mentioned_list?: string[];
};
}
interface WeComMarkdownMessage {
msgtype: 'markdown';
markdown: {
content: string;
};
}
type WeComMessage = WeComTextMessage | WeComMarkdownMessage;
interface WeComResponse {
errcode: number;
errmsg: string;
}
@Injectable()
export class WeComChannelService extends BaseChannelService {
name = 'WeCom';
type = 'wecom';
constructor(configService: ConfigService) {
super();
this.configService = configService;
}
async send(payload: NotificationPayload): Promise<NotificationSendResult> {
try {
if (this.isMockMode()) {
return this.createMockResult('wecom');
}
const webhookKey = payload.metadata?.webhookKey || 'WECOM_WEBHOOK_URL';
const webhookUrl = this.configService.get<string>(webhookKey);
if (!webhookUrl) {
return {
success: false,
error: `Webhook URL not configured: ${webhookKey}`,
};
}
const message = this.buildMessage(payload);
const response = await this.httpPost(webhookUrl, message);
if (response.errcode !== 0) {
return {
success: false,
error: `WeCom API error: ${response.errmsg} (code: ${response.errcode})`,
};
}
this.logger.log(`WeCom message sent successfully: ${payload.title}`);
return {
success: true,
message: 'WeCom message sent successfully',
channelMessageId: `wecom-${Date.now()}`,
};
} catch (error) {
return this.createErrorResult(error);
}
}
async isAvailable(): Promise<boolean> {
const webhookUrl = this.configService.get<string>('WECOM_WEBHOOK_URL');
return !!webhookUrl;
}
private buildMessage(payload: NotificationPayload): WeComMessage {
const mentionedList = payload.metadata?.mentioned_list || [];
const isMarkdown = !!payload.contentHtml || payload.metadata?.msgtype === 'markdown';
if (isMarkdown) {
const content = payload.contentHtml || payload.content;
return {
msgtype: 'markdown',
markdown: {
content: this.replaceVariables(content, payload.variables),
},
};
}
const content = payload.title
? `${payload.title}\n${payload.content}`
: payload.content;
return {
msgtype: 'text',
text: {
content: this.replaceVariables(content, payload.variables),
mentioned_list: mentionedList,
},
};
}
protected async httpPost(url: string, body: WeComMessage): Promise<WeComResponse> {
const response = await fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
if (!response.ok) {
throw new Error(`HTTP error: ${response.status} ${response.statusText}`);
}
return response.json();
}
}

View File

@ -0,0 +1,49 @@
import { Module, OnModuleInit } from '@nestjs/common';
import { NotificationController } from './notification.controller';
import { NotificationService } from './notification.service';
import { TemplateService } from './template.service';
import { PreferenceService } from './preference.service';
import { QueueService } from './queue.service';
import { ChannelFactoryService } from './channels/channel-factory.service';
import { EmailChannelService } from './channels/email-channel.service';
import { SmsChannelService } from './channels/sms-channel.service';
import { PushChannelService } from './channels/push-channel.service';
import { InAppChannelService } from './channels/in-app-channel.service';
import { WeComChannelService } from './channels/wecom-channel.service';
import { PrismaModule } from '../prisma/prisma.module';
@Module({
imports: [PrismaModule],
controllers: [NotificationController],
providers: [
NotificationService,
TemplateService,
PreferenceService,
QueueService,
ChannelFactoryService,
EmailChannelService,
SmsChannelService,
PushChannelService,
InAppChannelService,
WeComChannelService,
],
exports: [NotificationService, ChannelFactoryService],
})
export class NotificationModule implements OnModuleInit {
constructor(
private readonly channelFactory: ChannelFactoryService,
private readonly emailChannel: EmailChannelService,
private readonly smsChannel: SmsChannelService,
private readonly pushChannel: PushChannelService,
private readonly inAppChannel: InAppChannelService,
private readonly wecomChannel: WeComChannelService,
) {}
async onModuleInit() {
this.channelFactory.registerChannel(this.emailChannel);
this.channelFactory.registerChannel(this.smsChannel);
this.channelFactory.registerChannel(this.pushChannel);
this.channelFactory.registerChannel(this.inAppChannel);
this.channelFactory.registerChannel(this.wecomChannel);
}
}

View File

@ -0,0 +1,197 @@
import { Test, TestingModule } from '@nestjs/testing';
import { OrderController } from '../order.controller';
import { OrderService } from '../order.service';
import { OrderStatus } from '../types/order-status.enum';
describe('OrderController', () => {
let controller: OrderController;
let service: OrderService;
const mockOrderService = {
create: jest.fn(),
findOne: jest.fn(),
findMany: jest.fn(),
updateStatus: jest.fn(),
cancel: jest.fn(),
getStats: jest.fn(),
};
const mockRequest = {
user: { id: 'test-user-id' },
};
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
controllers: [OrderController],
providers: [
{
provide: OrderService,
useValue: mockOrderService,
},
],
}).compile();
controller = module.get<OrderController>(OrderController);
service = module.get<OrderService>(OrderService);
jest.clearAllMocks();
});
it('should be defined', () => {
expect(controller).toBeDefined();
});
describe('create', () => {
it('should create an order', async () => {
const createDto = {
items: [
{
productId: 'product-1',
productName: '测试商品',
quantity: 2,
unitPrice: 99.99,
},
],
remark: '请尽快发货',
};
const expectedResult = {
code: 201,
message: 'Order created successfully',
data: { id: 'order-id', orderNo: 'ORD001' },
};
mockOrderService.create.mockResolvedValue(expectedResult);
const result = await controller.create(createDto, mockRequest);
expect(result).toEqual(expectedResult);
expect(service.create).toHaveBeenCalledWith('test-user-id', createDto);
});
});
describe('findAll', () => {
it('should return paginated orders', async () => {
const expectedResult = {
code: 200,
message: 'Orders retrieved successfully',
data: {
orders: [],
pagination: { page: 1, limit: 20, total: 0, totalPages: 0 },
},
};
mockOrderService.findMany.mockResolvedValue(expectedResult);
const result = await controller.findAll('1', '20', undefined, mockRequest);
expect(result).toEqual(expectedResult);
expect(service.findMany).toHaveBeenCalledWith({
page: 1,
limit: 20,
userId: 'test-user-id',
status: undefined,
});
});
it('should filter by status', async () => {
const expectedResult = {
code: 200,
message: 'Orders retrieved successfully',
data: {
orders: [],
pagination: { page: 1, limit: 20, total: 0, totalPages: 0 },
},
};
mockOrderService.findMany.mockResolvedValue(expectedResult);
await controller.findAll('1', '20', OrderStatus.PENDING, mockRequest);
expect(service.findMany).toHaveBeenCalledWith({
page: 1,
limit: 20,
userId: 'test-user-id',
status: OrderStatus.PENDING,
});
});
});
describe('findOne', () => {
it('should return order by id', async () => {
const expectedResult = {
code: 200,
message: 'Order retrieved successfully',
data: { id: 'order-id', orderNo: 'ORD001' },
};
mockOrderService.findOne.mockResolvedValue(expectedResult);
const result = await controller.findOne('order-id', mockRequest);
expect(result).toEqual(expectedResult);
expect(service.findOne).toHaveBeenCalledWith('order-id', 'test-user-id');
});
});
describe('updateStatus', () => {
it('should update order status', async () => {
const updateDto = { status: OrderStatus.PAID };
const expectedResult = {
code: 200,
message: 'Order status updated successfully',
data: { id: 'order-id', status: OrderStatus.PAID },
};
mockOrderService.updateStatus.mockResolvedValue(expectedResult);
const result = await controller.updateStatus('order-id', updateDto);
expect(result).toEqual(expectedResult);
expect(service.updateStatus).toHaveBeenCalledWith('order-id', OrderStatus.PAID);
});
});
describe('cancel', () => {
it('should cancel an order', async () => {
const expectedResult = {
code: 200,
message: 'Order cancelled successfully',
data: { id: 'order-id', status: OrderStatus.CANCELLED },
};
mockOrderService.cancel.mockResolvedValue(expectedResult);
const result = await controller.cancel('order-id', mockRequest);
expect(result).toEqual(expectedResult);
expect(service.cancel).toHaveBeenCalledWith('order-id', 'test-user-id');
});
});
describe('getStats', () => {
it('should return order statistics', async () => {
const expectedResult = {
code: 200,
message: 'Order statistics retrieved successfully',
data: {
totalOrders: 10,
pendingOrders: 3,
paidOrders: 2,
shippedOrders: 1,
completedOrders: 2,
cancelledOrders: 1,
refundedOrders: 1,
totalAmount: 9999.99,
},
};
mockOrderService.getStats.mockResolvedValue(expectedResult);
const result = await controller.getStats(mockRequest);
expect(result).toEqual(expectedResult);
expect(service.getStats).toHaveBeenCalledWith('test-user-id');
});
});
});

View File

@ -0,0 +1,723 @@
import { Test, TestingModule } from '@nestjs/testing';
import {
NotFoundException,
BadRequestException,
} from '@nestjs/common';
import { OrderService } from '../order.service';
import { PrismaService } from '../../../prisma/prisma.service';
import { OrderStatus } from '../types/order-status.enum';
describe('OrderService', () => {
let service: OrderService;
let prisma: PrismaService;
const mockPrismaService = {
order: {
create: jest.fn(),
findUnique: jest.fn(),
findMany: jest.fn(),
count: jest.fn(),
update: jest.fn(),
aggregate: jest.fn(),
},
orderItem: {
createMany: jest.fn(),
},
};
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [
OrderService,
{
provide: PrismaService,
useValue: mockPrismaService,
},
],
}).compile();
service = module.get<OrderService>(OrderService);
prisma = module.get<PrismaService>(PrismaService);
jest.clearAllMocks();
});
it('should be defined', () => {
expect(service).toBeDefined();
});
describe('generateOrderNo', () => {
it('should generate order number with ORD prefix', () => {
const orderNo = service.generateOrderNo();
expect(orderNo).toMatch(/^ORD/);
expect(orderNo.length).toBeGreaterThan(10);
});
it('should generate unique order numbers', () => {
const orderNo1 = service.generateOrderNo();
const orderNo2 = service.generateOrderNo();
expect(orderNo1).not.toBe(orderNo2);
});
it('should follow format ORD + YYYYMMDDHHmmss + 6-digit random', () => {
const orderNo = service.generateOrderNo();
expect(orderNo).toMatch(/^ORD\d{14}\d{6}$/);
});
});
describe('create', () => {
const userId = 'test-user-id';
const createDto = {
items: [
{
productId: 'product-1',
productName: '测试商品A',
productImage: 'https://example.com/a.jpg',
quantity: 2,
unitPrice: 99.99,
},
{
productId: 'product-2',
productName: '测试商品B',
quantity: 1,
unitPrice: 199.00,
},
],
remark: '请尽快发货',
};
it('should create an order with correct total amount', async () => {
const expectedTotal = 2 * 99.99 + 1 * 199.0;
const mockOrder = {
id: 'order-id',
orderNo: 'ORD20260525120000000001',
userId,
status: OrderStatus.PENDING,
totalAmount: expectedTotal,
paidAmount: expectedTotal,
discountAmount: 0,
paymentMethod: null,
paymentOrderId: null,
remark: createDto.remark,
paidAt: null,
shippedAt: null,
completedAt: null,
cancelledAt: null,
createdAt: new Date(),
updatedAt: new Date(),
items: createDto.items.map((item, index) => ({
id: `item-id-${index}`,
orderId: 'order-id',
...item,
totalAmount: item.quantity * item.unitPrice,
createdAt: new Date(),
})),
};
mockPrismaService.order.create.mockResolvedValue(mockOrder);
const result = await service.create(userId, createDto);
expect(result.code).toBe(201);
expect(result.message).toBe('Order created successfully');
expect(result.data).toEqual(mockOrder);
expect(prisma.order.create).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.objectContaining({
userId,
status: OrderStatus.PENDING,
remark: createDto.remark,
}),
}),
);
});
it('should calculate total amount from items', async () => {
const mockOrder = {
id: 'order-id',
orderNo: 'ORD20260525120000000001',
userId,
status: OrderStatus.PENDING,
totalAmount: 398.98,
paidAmount: 398.98,
discountAmount: 0,
};
mockPrismaService.order.create.mockResolvedValue(mockOrder);
await service.create(userId, createDto);
const createCall = mockPrismaService.order.create.mock.calls[0][0];
expect(Number(createCall.data.totalAmount)).toBeCloseTo(398.98, 1);
expect(Number(createCall.data.paidAmount)).toBeCloseTo(398.98, 1);
});
it('should throw BadRequestException when items is empty', async () => {
const emptyDto = { items: [] };
await expect(service.create(userId, emptyDto)).rejects.toThrow(
BadRequestException,
);
});
it('should set discountAmount to 0 by default', async () => {
const mockOrder = {
id: 'order-id',
orderNo: 'ORD20260525120000000001',
userId,
status: OrderStatus.PENDING,
totalAmount: 398.98,
paidAmount: 398.98,
discountAmount: 0,
};
mockPrismaService.order.create.mockResolvedValue(mockOrder);
await service.create(userId, createDto);
const createCall = mockPrismaService.order.create.mock.calls[0][0];
expect(Number(createCall.data.discountAmount)).toBe(0);
});
});
describe('findOne', () => {
it('should return order with items', async () => {
const mockOrder = {
id: 'order-id',
orderNo: 'ORD20260525120000000001',
userId: 'test-user-id',
status: OrderStatus.PENDING,
totalAmount: 398.98,
paidAmount: 398.98,
discountAmount: 0,
items: [
{ id: 'item-1', productName: '商品A', quantity: 2, unitPrice: 99.99 },
],
};
mockPrismaService.order.findUnique.mockResolvedValue(mockOrder);
const result = await service.findOne('order-id');
expect(result.code).toBe(200);
expect(result.data.id).toBe('order-id');
expect(result.data.items).toHaveLength(1);
});
it('should throw NotFoundException when order not found', async () => {
mockPrismaService.order.findUnique.mockResolvedValue(null);
await expect(service.findOne('nonexistent')).rejects.toThrow(
NotFoundException,
);
});
it('should filter by userId when provided', async () => {
const mockOrder = {
id: 'order-id',
userId: 'user-1',
status: OrderStatus.PENDING,
items: [],
};
mockPrismaService.order.findUnique.mockResolvedValue(mockOrder);
await service.findOne('order-id', 'user-1');
expect(prisma.order.findUnique).toHaveBeenCalledWith(
expect.objectContaining({
where: { id: 'order-id', userId: 'user-1' },
}),
);
});
});
describe('findMany', () => {
it('should return paginated orders', async () => {
const mockOrders = [
{ id: '1', orderNo: 'ORD001', status: OrderStatus.PENDING, items: [] },
{ id: '2', orderNo: 'ORD002', status: OrderStatus.PAID, items: [] },
];
mockPrismaService.order.findMany.mockResolvedValue(mockOrders);
mockPrismaService.order.count.mockResolvedValue(2);
const result = await service.findMany({ page: 1, limit: 20 });
expect(result.code).toBe(200);
expect(result.data.orders).toHaveLength(2);
expect(result.data.pagination.total).toBe(2);
expect(result.data.pagination.page).toBe(1);
});
it('should filter by status', async () => {
mockPrismaService.order.findMany.mockResolvedValue([]);
mockPrismaService.order.count.mockResolvedValue(0);
await service.findMany({ page: 1, limit: 20, status: OrderStatus.PENDING });
expect(prisma.order.findMany).toHaveBeenCalledWith(
expect.objectContaining({
where: expect.objectContaining({
status: OrderStatus.PENDING,
}),
}),
);
});
it('should filter by userId', async () => {
mockPrismaService.order.findMany.mockResolvedValue([]);
mockPrismaService.order.count.mockResolvedValue(0);
await service.findMany({ page: 1, limit: 20, userId: 'user-1' });
expect(prisma.order.findMany).toHaveBeenCalledWith(
expect.objectContaining({
where: expect.objectContaining({
userId: 'user-1',
}),
}),
);
});
it('should calculate correct pagination', async () => {
mockPrismaService.order.findMany.mockResolvedValue([]);
mockPrismaService.order.count.mockResolvedValue(50);
const result = await service.findMany({ page: 2, limit: 10 });
expect(result.data.pagination.totalPages).toBe(5);
expect(result.data.pagination.page).toBe(2);
});
});
describe('updateStatus', () => {
it('should transition from PENDING to PAID', async () => {
const existingOrder = {
id: 'order-id',
status: OrderStatus.PENDING,
userId: 'user-1',
};
const updatedOrder = {
...existingOrder,
status: OrderStatus.PAID,
paidAt: new Date(),
};
mockPrismaService.order.findUnique.mockResolvedValue(existingOrder);
mockPrismaService.order.update.mockResolvedValue(updatedOrder);
const result = await service.updateStatus('order-id', OrderStatus.PAID);
expect(result.code).toBe(200);
expect(result.data.status).toBe(OrderStatus.PAID);
});
it('should transition from PENDING to CANCELLED', async () => {
const existingOrder = {
id: 'order-id',
status: OrderStatus.PENDING,
userId: 'user-1',
};
const updatedOrder = {
...existingOrder,
status: OrderStatus.CANCELLED,
cancelledAt: new Date(),
};
mockPrismaService.order.findUnique.mockResolvedValue(existingOrder);
mockPrismaService.order.update.mockResolvedValue(updatedOrder);
const result = await service.updateStatus('order-id', OrderStatus.CANCELLED);
expect(result.code).toBe(200);
expect(result.data.status).toBe(OrderStatus.CANCELLED);
});
it('should transition from PAID to SHIPPED', async () => {
const existingOrder = {
id: 'order-id',
status: OrderStatus.PAID,
userId: 'user-1',
};
const updatedOrder = {
...existingOrder,
status: OrderStatus.SHIPPED,
shippedAt: new Date(),
};
mockPrismaService.order.findUnique.mockResolvedValue(existingOrder);
mockPrismaService.order.update.mockResolvedValue(updatedOrder);
const result = await service.updateStatus('order-id', OrderStatus.SHIPPED);
expect(result.code).toBe(200);
expect(result.data.status).toBe(OrderStatus.SHIPPED);
});
it('should transition from PAID to REFUNDED', async () => {
const existingOrder = {
id: 'order-id',
status: OrderStatus.PAID,
userId: 'user-1',
};
const updatedOrder = {
...existingOrder,
status: OrderStatus.REFUNDED,
};
mockPrismaService.order.findUnique.mockResolvedValue(existingOrder);
mockPrismaService.order.update.mockResolvedValue(updatedOrder);
const result = await service.updateStatus('order-id', OrderStatus.REFUNDED);
expect(result.code).toBe(200);
expect(result.data.status).toBe(OrderStatus.REFUNDED);
});
it('should transition from SHIPPED to COMPLETED', async () => {
const existingOrder = {
id: 'order-id',
status: OrderStatus.SHIPPED,
userId: 'user-1',
};
const updatedOrder = {
...existingOrder,
status: OrderStatus.COMPLETED,
completedAt: new Date(),
};
mockPrismaService.order.findUnique.mockResolvedValue(existingOrder);
mockPrismaService.order.update.mockResolvedValue(updatedOrder);
const result = await service.updateStatus('order-id', OrderStatus.COMPLETED);
expect(result.code).toBe(200);
expect(result.data.status).toBe(OrderStatus.COMPLETED);
});
it('should transition from SHIPPED to REFUNDED', async () => {
const existingOrder = {
id: 'order-id',
status: OrderStatus.SHIPPED,
userId: 'user-1',
};
const updatedOrder = {
...existingOrder,
status: OrderStatus.REFUNDED,
};
mockPrismaService.order.findUnique.mockResolvedValue(existingOrder);
mockPrismaService.order.update.mockResolvedValue(updatedOrder);
const result = await service.updateStatus('order-id', OrderStatus.REFUNDED);
expect(result.code).toBe(200);
expect(result.data.status).toBe(OrderStatus.REFUNDED);
});
it('should reject invalid transition from PENDING to SHIPPED', async () => {
const existingOrder = {
id: 'order-id',
status: OrderStatus.PENDING,
userId: 'user-1',
};
mockPrismaService.order.findUnique.mockResolvedValue(existingOrder);
await expect(
service.updateStatus('order-id', OrderStatus.SHIPPED),
).rejects.toThrow(BadRequestException);
});
it('should reject invalid transition from PENDING to COMPLETED', async () => {
const existingOrder = {
id: 'order-id',
status: OrderStatus.PENDING,
userId: 'user-1',
};
mockPrismaService.order.findUnique.mockResolvedValue(existingOrder);
await expect(
service.updateStatus('order-id', OrderStatus.COMPLETED),
).rejects.toThrow(BadRequestException);
});
it('should reject invalid transition from CANCELLED to PAID', async () => {
const existingOrder = {
id: 'order-id',
status: OrderStatus.CANCELLED,
userId: 'user-1',
};
mockPrismaService.order.findUnique.mockResolvedValue(existingOrder);
await expect(
service.updateStatus('order-id', OrderStatus.PAID),
).rejects.toThrow(BadRequestException);
});
it('should reject invalid transition from COMPLETED to PENDING', async () => {
const existingOrder = {
id: 'order-id',
status: OrderStatus.COMPLETED,
userId: 'user-1',
};
mockPrismaService.order.findUnique.mockResolvedValue(existingOrder);
await expect(
service.updateStatus('order-id', OrderStatus.PENDING),
).rejects.toThrow(BadRequestException);
});
it('should reject transition from REFUNDED to any status', async () => {
const existingOrder = {
id: 'order-id',
status: OrderStatus.REFUNDED,
userId: 'user-1',
};
mockPrismaService.order.findUnique.mockResolvedValue(existingOrder);
await expect(
service.updateStatus('order-id', OrderStatus.PAID),
).rejects.toThrow(BadRequestException);
});
it('should throw NotFoundException when order not found', async () => {
mockPrismaService.order.findUnique.mockResolvedValue(null);
await expect(
service.updateStatus('nonexistent', OrderStatus.PAID),
).rejects.toThrow(NotFoundException);
});
it('should set paidAt when transitioning to PAID', async () => {
const existingOrder = {
id: 'order-id',
status: OrderStatus.PENDING,
};
mockPrismaService.order.findUnique.mockResolvedValue(existingOrder);
mockPrismaService.order.update.mockResolvedValue({
...existingOrder,
status: OrderStatus.PAID,
});
await service.updateStatus('order-id', OrderStatus.PAID);
expect(prisma.order.update).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.objectContaining({
status: OrderStatus.PAID,
paidAt: expect.any(Date),
}),
}),
);
});
it('should set shippedAt when transitioning to SHIPPED', async () => {
const existingOrder = {
id: 'order-id',
status: OrderStatus.PAID,
};
mockPrismaService.order.findUnique.mockResolvedValue(existingOrder);
mockPrismaService.order.update.mockResolvedValue({
...existingOrder,
status: OrderStatus.SHIPPED,
});
await service.updateStatus('order-id', OrderStatus.SHIPPED);
expect(prisma.order.update).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.objectContaining({
status: OrderStatus.SHIPPED,
shippedAt: expect.any(Date),
}),
}),
);
});
it('should set completedAt when transitioning to COMPLETED', async () => {
const existingOrder = {
id: 'order-id',
status: OrderStatus.SHIPPED,
};
mockPrismaService.order.findUnique.mockResolvedValue(existingOrder);
mockPrismaService.order.update.mockResolvedValue({
...existingOrder,
status: OrderStatus.COMPLETED,
});
await service.updateStatus('order-id', OrderStatus.COMPLETED);
expect(prisma.order.update).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.objectContaining({
status: OrderStatus.COMPLETED,
completedAt: expect.any(Date),
}),
}),
);
});
});
describe('cancel', () => {
it('should cancel a PENDING order', async () => {
const existingOrder = {
id: 'order-id',
status: OrderStatus.PENDING,
userId: 'user-1',
};
const cancelledOrder = {
...existingOrder,
status: OrderStatus.CANCELLED,
cancelledAt: new Date(),
};
mockPrismaService.order.findUnique.mockResolvedValue(existingOrder);
mockPrismaService.order.update.mockResolvedValue(cancelledOrder);
const result = await service.cancel('order-id', 'user-1');
expect(result.code).toBe(200);
expect(result.data.status).toBe(OrderStatus.CANCELLED);
});
it('should throw BadRequestException when cancelling non-PENDING order', async () => {
const existingOrder = {
id: 'order-id',
status: OrderStatus.PAID,
userId: 'user-1',
};
mockPrismaService.order.findUnique.mockResolvedValue(existingOrder);
await expect(service.cancel('order-id', 'user-1')).rejects.toThrow(
BadRequestException,
);
});
it('should throw NotFoundException when order not found', async () => {
mockPrismaService.order.findUnique.mockResolvedValue(null);
await expect(service.cancel('nonexistent', 'user-1')).rejects.toThrow(
NotFoundException,
);
});
});
describe('getStats', () => {
it('should return order statistics', async () => {
mockPrismaService.order.count
.mockResolvedValueOnce(10)
.mockResolvedValueOnce(3)
.mockResolvedValueOnce(2)
.mockResolvedValueOnce(1)
.mockResolvedValueOnce(2)
.mockResolvedValueOnce(1)
.mockResolvedValueOnce(1);
mockPrismaService.order.aggregate.mockResolvedValue({
_sum: { totalAmount: 9999.99 },
});
const result = await service.getStats('user-1');
expect(result.code).toBe(200);
expect(result.data).toHaveProperty('totalOrders');
expect(result.data).toHaveProperty('pendingOrders');
expect(result.data).toHaveProperty('paidOrders');
expect(result.data).toHaveProperty('shippedOrders');
expect(result.data).toHaveProperty('completedOrders');
expect(result.data).toHaveProperty('cancelledOrders');
expect(result.data).toHaveProperty('refundedOrders');
expect(result.data).toHaveProperty('totalAmount');
});
});
describe('validateStatusTransition', () => {
it('should allow PENDING -> PAID', () => {
expect(
service.validateStatusTransition(OrderStatus.PENDING, OrderStatus.PAID),
).toBe(true);
});
it('should allow PENDING -> CANCELLED', () => {
expect(
service.validateStatusTransition(OrderStatus.PENDING, OrderStatus.CANCELLED),
).toBe(true);
});
it('should allow PAID -> SHIPPED', () => {
expect(
service.validateStatusTransition(OrderStatus.PAID, OrderStatus.SHIPPED),
).toBe(true);
});
it('should allow PAID -> REFUNDED', () => {
expect(
service.validateStatusTransition(OrderStatus.PAID, OrderStatus.REFUNDED),
).toBe(true);
});
it('should allow SHIPPED -> COMPLETED', () => {
expect(
service.validateStatusTransition(OrderStatus.SHIPPED, OrderStatus.COMPLETED),
).toBe(true);
});
it('should allow SHIPPED -> REFUNDED', () => {
expect(
service.validateStatusTransition(OrderStatus.SHIPPED, OrderStatus.REFUNDED),
).toBe(true);
});
it('should reject PENDING -> SHIPPED', () => {
expect(
service.validateStatusTransition(OrderStatus.PENDING, OrderStatus.SHIPPED),
).toBe(false);
});
it('should reject PENDING -> COMPLETED', () => {
expect(
service.validateStatusTransition(OrderStatus.PENDING, OrderStatus.COMPLETED),
).toBe(false);
});
it('should reject CANCELLED -> any', () => {
expect(
service.validateStatusTransition(OrderStatus.CANCELLED, OrderStatus.PAID),
).toBe(false);
expect(
service.validateStatusTransition(OrderStatus.CANCELLED, OrderStatus.SHIPPED),
).toBe(false);
});
it('should reject REFUNDED -> any', () => {
expect(
service.validateStatusTransition(OrderStatus.REFUNDED, OrderStatus.PAID),
).toBe(false);
expect(
service.validateStatusTransition(OrderStatus.REFUNDED, OrderStatus.COMPLETED),
).toBe(false);
});
it('should reject COMPLETED -> any', () => {
expect(
service.validateStatusTransition(OrderStatus.COMPLETED, OrderStatus.PAID),
).toBe(false);
});
it('should reject same status transition', () => {
expect(
service.validateStatusTransition(OrderStatus.PENDING, OrderStatus.PENDING),
).toBe(false);
});
});
});

View File

@ -0,0 +1,45 @@
import {
IsString,
IsNotEmpty,
IsOptional,
IsArray,
ValidateNested,
IsNumber,
Min,
ArrayMinSize,
} from 'class-validator';
import { Type } from 'class-transformer';
export class OrderItemDto {
@IsString()
@IsNotEmpty()
productId: string;
@IsString()
@IsNotEmpty()
productName: string;
@IsString()
@IsOptional()
productImage?: string;
@IsNumber()
@Min(1)
quantity: number;
@IsNumber()
@Min(0)
unitPrice: number;
}
export class CreateOrderDto {
@IsArray()
@ArrayMinSize(1)
@ValidateNested({ each: true })
@Type(() => OrderItemDto)
items: OrderItemDto[];
@IsString()
@IsOptional()
remark?: string;
}

View File

@ -0,0 +1,25 @@
import { IsOptional, IsEnum, IsNumber, Min, IsString } from 'class-validator';
import { Transform } from 'class-transformer';
import { OrderStatus } from '../types/order-status.enum';
export class QueryOrderDto {
@Transform(({ value }) => parseInt(value, 10))
@IsNumber()
@Min(1)
@IsOptional()
page?: number = 1;
@Transform(({ value }) => parseInt(value, 10))
@IsNumber()
@Min(1)
@IsOptional()
limit?: number = 20;
@IsEnum(OrderStatus)
@IsOptional()
status?: OrderStatus;
@IsString()
@IsOptional()
userId?: string;
}

View File

@ -0,0 +1,7 @@
import { IsEnum } from 'class-validator';
import { OrderStatus } from '../types/order-status.enum';
export class UpdateOrderStatusDto {
@IsEnum(OrderStatus)
status: OrderStatus;
}

View File

@ -0,0 +1,69 @@
import {
Controller,
Get,
Post,
Put,
Body,
Param,
Query,
Request,
UseGuards,
} from '@nestjs/common';
import { OrderService } from './order.service';
import { CreateOrderDto } from './dto/create-order.dto';
import { UpdateOrderStatusDto } from './dto/update-order-status.dto';
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
@Controller('orders')
@UseGuards(JwtAuthGuard)
export class OrderController {
constructor(private readonly orderService: OrderService) {}
@Post()
async create(@Body() createOrderDto: CreateOrderDto, @Request() req: any) {
const userId = req.user?.id;
return this.orderService.create(userId, createOrderDto);
}
@Get()
async findAll(
@Query('page') page: string = '1',
@Query('limit') limit: string = '20',
@Query('status') status?: string,
@Request() req?: any,
) {
const userId = req.user?.id;
return this.orderService.findMany({
page: parseInt(page, 10),
limit: parseInt(limit, 10),
userId,
status: status as any,
});
}
@Get('stats')
async getStats(@Request() req: any) {
const userId = req.user?.id;
return this.orderService.getStats(userId);
}
@Get(':id')
async findOne(@Param('id') id: string, @Request() req: any) {
const userId = req.user?.id;
return this.orderService.findOne(id, userId);
}
@Put(':id/status')
async updateStatus(
@Param('id') id: string,
@Body() updateOrderStatusDto: UpdateOrderStatusDto,
) {
return this.orderService.updateStatus(id, updateOrderStatusDto.status);
}
@Post(':id/cancel')
async cancel(@Param('id') id: string, @Request() req: any) {
const userId = req.user?.id;
return this.orderService.cancel(id, userId);
}
}

View File

@ -0,0 +1,10 @@
import { Module } from '@nestjs/common';
import { OrderController } from './order.controller';
import { OrderService } from './order.service';
@Module({
controllers: [OrderController],
providers: [OrderService],
exports: [OrderService],
})
export class OrderModule {}

View File

@ -0,0 +1,261 @@
import {
Injectable,
NotFoundException,
BadRequestException,
} from '@nestjs/common';
import { PrismaService } from '../../prisma/prisma.service';
import { Prisma } from '@prisma/client';
import { OrderStatus } from './types/order-status.enum';
import { CreateOrderDto } from './dto/create-order.dto';
const VALID_TRANSITIONS: Record<OrderStatus, OrderStatus[]> = {
[OrderStatus.PENDING]: [OrderStatus.PAID, OrderStatus.CANCELLED],
[OrderStatus.PAID]: [OrderStatus.SHIPPED, OrderStatus.REFUNDED],
[OrderStatus.SHIPPED]: [OrderStatus.COMPLETED, OrderStatus.REFUNDED],
[OrderStatus.COMPLETED]: [],
[OrderStatus.CANCELLED]: [],
[OrderStatus.REFUNDED]: [],
};
const STATUS_TIMESTAMP_MAP: Partial<Record<OrderStatus, string>> = {
[OrderStatus.PAID]: 'paidAt',
[OrderStatus.SHIPPED]: 'shippedAt',
[OrderStatus.COMPLETED]: 'completedAt',
[OrderStatus.CANCELLED]: 'cancelledAt',
};
@Injectable()
export class OrderService {
constructor(private prisma: PrismaService) {}
generateOrderNo(): string {
const now = new Date();
const pad = (n: number) => n.toString().padStart(2, '0');
const timestamp = `${now.getFullYear()}${pad(now.getMonth() + 1)}${pad(now.getDate())}${pad(now.getHours())}${pad(now.getMinutes())}${pad(now.getSeconds())}`;
const random = Math.floor(Math.random() * 1000000).toString().padStart(6, '0');
return `ORD${timestamp}${random}`;
}
validateStatusTransition(
currentStatus: OrderStatus,
targetStatus: OrderStatus,
): boolean {
if (currentStatus === targetStatus) return false;
const allowed = VALID_TRANSITIONS[currentStatus];
if (!allowed) return false;
return allowed.includes(targetStatus);
}
private getStatusUpdateData(targetStatus: OrderStatus): Record<string, unknown> {
const data: Record<string, unknown> = { status: targetStatus };
const timestampField = STATUS_TIMESTAMP_MAP[targetStatus];
if (timestampField) {
data[timestampField] = new Date();
}
return data;
}
private calculateTotalAmount(items: CreateOrderDto['items']): number {
return items.reduce((sum, item) => sum + item.quantity * item.unitPrice, 0);
}
async create(userId: string, createDto: CreateOrderDto) {
const { items, remark } = createDto;
if (!items || items.length === 0) {
throw new BadRequestException('Order must have at least one item');
}
const orderNo = this.generateOrderNo();
const totalAmount = this.calculateTotalAmount(items);
const order = await this.prisma.order.create({
data: {
orderNo,
userId,
status: OrderStatus.PENDING,
totalAmount: new Prisma.Decimal(totalAmount),
paidAmount: new Prisma.Decimal(totalAmount),
discountAmount: new Prisma.Decimal(0),
remark,
items: {
create: items.map((item) => ({
productId: item.productId,
productName: item.productName,
productImage: item.productImage,
quantity: item.quantity,
unitPrice: new Prisma.Decimal(item.unitPrice),
totalAmount: new Prisma.Decimal(item.quantity * item.unitPrice),
})),
},
},
include: { items: true },
});
return {
code: 201,
message: 'Order created successfully',
data: order,
};
}
async findOne(id: string, userId?: string) {
const where: Prisma.OrderWhereUniqueInput = { id };
if (userId) {
(where as Record<string, unknown>).userId = userId;
}
const order = await this.prisma.order.findUnique({
where,
include: { items: true },
});
if (!order) {
throw new NotFoundException('Order not found');
}
return {
code: 200,
message: 'Order retrieved successfully',
data: order,
};
}
async findMany(query: {
page: number;
limit: number;
userId?: string;
status?: OrderStatus;
}) {
const { page, limit, userId, status } = query;
const skip = (page - 1) * limit;
const where: Prisma.OrderWhereInput = {};
if (userId) {
where.userId = userId;
}
if (status) {
where.status = status;
}
const [orders, total] = await Promise.all([
this.prisma.order.findMany({
where,
orderBy: { createdAt: 'desc' },
skip,
take: limit,
include: { items: true },
}),
this.prisma.order.count({ where }),
]);
return {
code: 200,
message: 'Orders retrieved successfully',
data: {
orders,
pagination: {
page,
limit,
total,
totalPages: Math.ceil(total / limit),
},
},
};
}
async updateStatus(id: string, targetStatus: OrderStatus) {
const order = await this.prisma.order.findUnique({ where: { id } });
if (!order) {
throw new NotFoundException('Order not found');
}
if (!this.validateStatusTransition(order.status as OrderStatus, targetStatus)) {
throw new BadRequestException(
`Invalid status transition from ${order.status} to ${targetStatus}`,
);
}
const updateData = this.getStatusUpdateData(targetStatus);
const updated = await this.prisma.order.update({
where: { id },
data: updateData,
include: { items: true },
});
return {
code: 200,
message: 'Order status updated successfully',
data: updated,
};
}
async cancel(id: string, userId?: string) {
const where: Prisma.OrderWhereUniqueInput = { id };
if (userId) {
(where as Record<string, unknown>).userId = userId;
}
const order = await this.prisma.order.findUnique({ where });
if (!order) {
throw new NotFoundException('Order not found');
}
if (order.status !== OrderStatus.PENDING) {
throw new BadRequestException('Only pending orders can be cancelled');
}
const updated = await this.prisma.order.update({
where: { id },
data: {
status: OrderStatus.CANCELLED,
cancelledAt: new Date(),
},
include: { items: true },
});
return {
code: 200,
message: 'Order cancelled successfully',
data: updated,
};
}
async getStats(userId?: string) {
const where: Prisma.OrderWhereInput = {};
if (userId) {
where.userId = userId;
}
const statusCounts = await Promise.all(
Object.values(OrderStatus).map((status) =>
this.prisma.order.count({ where: { ...where, status } }),
),
);
const amountResult = await this.prisma.order.aggregate({
where,
_sum: { totalAmount: true },
});
const [totalOrders, pendingOrders, paidOrders, shippedOrders, completedOrders, cancelledOrders, refundedOrders] = statusCounts;
return {
code: 200,
message: 'Order statistics retrieved successfully',
data: {
totalOrders,
pendingOrders,
paidOrders,
shippedOrders,
completedOrders,
cancelledOrders,
refundedOrders,
totalAmount: amountResult._sum.totalAmount || 0,
},
};
}
}

View File

@ -0,0 +1,8 @@
export enum OrderStatus {
PENDING = 'PENDING',
PAID = 'PAID',
SHIPPED = 'SHIPPED',
COMPLETED = 'COMPLETED',
CANCELLED = 'CANCELLED',
REFUNDED = 'REFUNDED',
}