feat: Phase 1-3 全部完成 — 沙盒增强、学情分析、学习路径

This commit is contained in:
yuzhiran-dev
2026-05-18 09:48:51 +08:00
commit 11bb86854c
277 changed files with 37755 additions and 0 deletions
@@ -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);
});
});
});