feat: Phase 1-3 全部完成 — 沙盒增强、学情分析、学习路径
This commit is contained in:
@@ -0,0 +1,42 @@
|
||||
import { Controller, Post, Get, Body, Param, Query, UseGuards, Req } from '@nestjs/common';
|
||||
import { ApiTags, ApiBearerAuth, ApiBody } from '@nestjs/swagger';
|
||||
import { IsNumber, IsString, IsOptional, IsIn } from 'class-validator';
|
||||
import { AuthGuard } from '@nestjs/passport';
|
||||
import { OrdersService } from './orders.service';
|
||||
|
||||
class CreateOrderDto {
|
||||
@IsNumber()
|
||||
amount: number;
|
||||
|
||||
@IsString()
|
||||
@IsIn(['MONTHLY', 'YEARLY', 'COURSE'])
|
||||
planType: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
payChannel?: string;
|
||||
}
|
||||
|
||||
@ApiTags('订单')
|
||||
@Controller('orders')
|
||||
@UseGuards(AuthGuard('jwt'))
|
||||
@ApiBearerAuth()
|
||||
export class OrdersController {
|
||||
constructor(private ordersService: OrdersService) {}
|
||||
|
||||
@Post('create')
|
||||
@ApiBody({ type: CreateOrderDto })
|
||||
async create(@Req() req: any, @Body() body: CreateOrderDto) {
|
||||
return this.ordersService.create(req.user.userId, body);
|
||||
}
|
||||
|
||||
@Get()
|
||||
async findByUser(@Req() req: any, @Query() query: { page?: number; pageSize?: number }) {
|
||||
return this.ordersService.findByUser(req.user.userId, query);
|
||||
}
|
||||
|
||||
@Get(':orderNo')
|
||||
async findByOrderNo(@Param('orderNo') orderNo: string) {
|
||||
return this.ordersService.findByOrderNo(orderNo);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { OrdersController } from './orders.controller';
|
||||
import { OrdersService } from './orders.service';
|
||||
import { PaymentModule } from '../payment/payment.module';
|
||||
|
||||
@Module({
|
||||
imports: [PaymentModule],
|
||||
controllers: [OrdersController],
|
||||
providers: [OrdersService],
|
||||
exports: [OrdersService],
|
||||
})
|
||||
export class OrdersModule {}
|
||||
@@ -0,0 +1,83 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { PrismaService } from '../../prisma/prisma.service';
|
||||
import { PaymentService } from '../payment/payment.service';
|
||||
|
||||
@Injectable()
|
||||
export class OrdersService {
|
||||
constructor(
|
||||
private prisma: PrismaService,
|
||||
private paymentService: PaymentService,
|
||||
) {}
|
||||
|
||||
async create(userId: number, data: { amount: number; planType: string; payChannel?: string }) {
|
||||
const orderNo = `YZR${Date.now()}${Math.random().toString(36).slice(2, 8).toUpperCase()}`;
|
||||
|
||||
const order = await this.prisma.order.create({
|
||||
data: {
|
||||
orderNo,
|
||||
userId,
|
||||
amount: data.amount,
|
||||
planType: data.planType,
|
||||
payChannel: data.payChannel || 'wxpay',
|
||||
},
|
||||
});
|
||||
|
||||
// If paying via WeChat, create unified order
|
||||
if (data.payChannel === 'wxpay' || !data.payChannel) {
|
||||
try {
|
||||
const planLabels: Record<string, string> = {
|
||||
MONTHLY: '宇之然AI月卡会员',
|
||||
YEARLY: '宇之然AI年卡会员',
|
||||
};
|
||||
const payResult = await this.paymentService.createUnifiedOrder({
|
||||
description: planLabels[data.planType] || '宇之然AI会员充值',
|
||||
outTradeNo: orderNo,
|
||||
amount: data.amount,
|
||||
});
|
||||
return { order, payResult };
|
||||
} catch {
|
||||
return { order, payResult: null };
|
||||
}
|
||||
}
|
||||
|
||||
return { order };
|
||||
}
|
||||
|
||||
async findByUser(userId: number, params: { page?: number; pageSize?: number }) {
|
||||
const page = Number(params.page ?? 1);
|
||||
const pageSize = Number(params.pageSize ?? 20);
|
||||
const [items, total] = await Promise.all([
|
||||
this.prisma.order.findMany({
|
||||
where: { userId },
|
||||
skip: (page - 1) * pageSize,
|
||||
take: pageSize,
|
||||
orderBy: { createdAt: 'desc' },
|
||||
}),
|
||||
this.prisma.order.count({ where: { userId } }),
|
||||
]);
|
||||
return { items, total, page, pageSize };
|
||||
}
|
||||
|
||||
async findByOrderNo(orderNo: string) {
|
||||
return this.prisma.order.findUnique({ where: { orderNo } });
|
||||
}
|
||||
|
||||
async getCurrentSubscription(userId: number) {
|
||||
const now = new Date();
|
||||
return this.prisma.subscription.findFirst({
|
||||
where: {
|
||||
userId,
|
||||
status: 'ACTIVE',
|
||||
endDate: { gt: now },
|
||||
},
|
||||
orderBy: { endDate: 'desc' },
|
||||
});
|
||||
}
|
||||
|
||||
async getSubscriptions(userId: number) {
|
||||
return this.prisma.subscription.findMany({
|
||||
where: { userId },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import { Controller, Get, Post, Body, UseGuards, Req } from '@nestjs/common';
|
||||
import { ApiTags, ApiBearerAuth } from '@nestjs/swagger';
|
||||
import { AuthGuard } from '@nestjs/passport';
|
||||
import { OrdersService } from './orders.service';
|
||||
|
||||
@ApiTags('订阅')
|
||||
@Controller('subscriptions')
|
||||
@UseGuards(AuthGuard('jwt'))
|
||||
@ApiBearerAuth()
|
||||
export class SubscriptionsController {
|
||||
constructor(private ordersService: OrdersService) {}
|
||||
|
||||
@Get('current')
|
||||
async getCurrentSubscription(@Req() req: any) {
|
||||
return this.ordersService.getCurrentSubscription(req.user.userId);
|
||||
}
|
||||
|
||||
@Get()
|
||||
async getSubscriptions(@Req() req: any) {
|
||||
return this.ordersService.getSubscriptions(req.user.userId);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,169 @@
|
||||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
import { OrdersService } from '../orders.service';
|
||||
import { PrismaService } from '../../../prisma/prisma.service';
|
||||
import { PaymentService } from '../../payment/payment.service';
|
||||
|
||||
describe('OrdersService', () => {
|
||||
let service: OrdersService;
|
||||
let prisma: PrismaService;
|
||||
let paymentService: PaymentService;
|
||||
|
||||
const mockPrisma = {
|
||||
order: {
|
||||
create: jest.fn(),
|
||||
findMany: jest.fn(),
|
||||
findUnique: jest.fn(),
|
||||
update: jest.fn(),
|
||||
count: jest.fn(),
|
||||
},
|
||||
subscription: {
|
||||
findFirst: jest.fn(),
|
||||
findMany: jest.fn(),
|
||||
},
|
||||
};
|
||||
|
||||
const mockPaymentService = {
|
||||
createUnifiedOrder: jest.fn(),
|
||||
};
|
||||
|
||||
beforeEach(async () => {
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
providers: [
|
||||
OrdersService,
|
||||
{ provide: PrismaService, useValue: mockPrisma },
|
||||
{ provide: PaymentService, useValue: mockPaymentService },
|
||||
],
|
||||
}).compile();
|
||||
|
||||
service = module.get<OrdersService>(OrdersService);
|
||||
prisma = module.get<PrismaService>(PrismaService);
|
||||
paymentService = module.get<PaymentService>(PaymentService);
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('create', () => {
|
||||
it('should create order and call payment service for wxpay', async () => {
|
||||
const mockOrder = {
|
||||
id: 1,
|
||||
orderNo: 'YZR123',
|
||||
amount: 29.9,
|
||||
planType: 'MONTHLY',
|
||||
payChannel: 'wxpay',
|
||||
};
|
||||
mockPrisma.order.create.mockResolvedValue(mockOrder);
|
||||
mockPaymentService.createUnifiedOrder.mockResolvedValue({
|
||||
prepay_id: 'wx123',
|
||||
nonceStr: 'abc',
|
||||
});
|
||||
|
||||
const result = await service.create(1, {
|
||||
amount: 29.9,
|
||||
planType: 'MONTHLY',
|
||||
payChannel: 'wxpay',
|
||||
});
|
||||
|
||||
expect(result).toHaveProperty('order');
|
||||
expect(result).toHaveProperty('payResult');
|
||||
expect(mockPrisma.order.create).toHaveBeenCalledWith({
|
||||
data: expect.objectContaining({
|
||||
userId: 1,
|
||||
amount: 29.9,
|
||||
planType: 'MONTHLY',
|
||||
payChannel: 'wxpay',
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
it('should create order without payment for non-wxpay channels', async () => {
|
||||
const mockOrder = {
|
||||
id: 1,
|
||||
orderNo: 'YZR123',
|
||||
amount: 199,
|
||||
planType: 'YEARLY',
|
||||
payChannel: 'alipay',
|
||||
};
|
||||
mockPrisma.order.create.mockResolvedValue(mockOrder);
|
||||
|
||||
const result = await service.create(1, {
|
||||
amount: 199,
|
||||
planType: 'YEARLY',
|
||||
payChannel: 'alipay',
|
||||
});
|
||||
|
||||
expect(result).toEqual({ order: mockOrder });
|
||||
expect(mockPaymentService.createUnifiedOrder).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('findByUser', () => {
|
||||
it('should return paginated orders for user', async () => {
|
||||
const mockOrders = [
|
||||
{ id: 1, orderNo: 'YZR123', amount: 29.9 }
|
||||
];
|
||||
mockPrisma.order.findMany.mockResolvedValue(mockOrders);
|
||||
mockPrisma.order.count.mockResolvedValue(1);
|
||||
|
||||
const result = await service.findByUser(1, { page: 1, pageSize: 20 });
|
||||
|
||||
expect(result).toEqual({
|
||||
items: mockOrders,
|
||||
total: 1,
|
||||
page: 1,
|
||||
pageSize: 20,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('findByOrderNo', () => {
|
||||
it('should return order by orderNo', async () => {
|
||||
const mockOrder = { id: 1, orderNo: 'YZR123' };
|
||||
mockPrisma.order.findUnique.mockResolvedValue(mockOrder);
|
||||
|
||||
const result = await service.findByOrderNo('YZR123');
|
||||
|
||||
expect(result).toEqual(mockOrder);
|
||||
expect(mockPrisma.order.findUnique).toHaveBeenCalledWith({
|
||||
where: { orderNo: 'YZR123' },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('getCurrentSubscription', () => {
|
||||
it('should return active subscription', async () => {
|
||||
const mockSub = {
|
||||
id: 1,
|
||||
userId: 1,
|
||||
plan: 'YEARLY',
|
||||
status: 'ACTIVE',
|
||||
endDate: new Date(Date.now() + 86400000), // 明天到期
|
||||
};
|
||||
mockPrisma.subscription.findFirst.mockResolvedValue(mockSub);
|
||||
|
||||
const result = await service.getCurrentSubscription(1);
|
||||
|
||||
expect(result).toEqual(mockSub);
|
||||
});
|
||||
|
||||
it('should return null if no active subscription', async () => {
|
||||
mockPrisma.subscription.findFirst.mockResolvedValue(null);
|
||||
|
||||
const result = await service.getCurrentSubscription(1);
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('getSubscriptions', () => {
|
||||
it('should return all subscriptions for user', async () => {
|
||||
const mockSubs = [
|
||||
{ id: 1, plan: 'MONTHLY' },
|
||||
{ id: 2, plan: 'YEARLY' },
|
||||
];
|
||||
mockPrisma.subscription.findMany.mockResolvedValue(mockSubs);
|
||||
|
||||
const result = await service.getSubscriptions(1);
|
||||
|
||||
expect(result).toEqual(mockSubs);
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user