feat: 迭代

This commit is contained in:
2026-05-15 19:06:32 +08:00
parent 8c908ea557
commit 848df4c873
22 changed files with 1545 additions and 355 deletions
+2 -2
View File
@@ -12,8 +12,8 @@ REDIS_PASSWORD=
# JWT配置
JWT_SECRET=your_jwt_secret_key_change_in_production
JWT_EXPIRES_IN=2h
JWT_REFRESH_EXPIRES_IN=7d
JWT_EXPIRES_IN=30d
JWT_REFRESH_EXPIRES_IN=30d
# 应用配置
PORT=3000
@@ -7,6 +7,7 @@ import {
RegisterDto,
SendCodeDto,
WechatLoginDto,
WechatPhoneLoginDto,
} from './dto/auth.dto';
import { JwtAuthGuard } from '@/common/guards/jwt-auth.guard';
@@ -39,6 +40,12 @@ export class AuthController {
return this.authService.loginByWechat(dto);
}
@Post('login/wechat-phone')
@ApiOperation({ summary: '微信手机号一键登录' })
async loginByWechatPhone(@Body() dto: WechatPhoneLoginDto) {
return this.authService.loginByWechatPhone(dto);
}
@Post('register')
@ApiOperation({ summary: '用户注册' })
async register(@Body() dto: RegisterDto) {
@@ -9,6 +9,7 @@ import { ConfigService } from '@nestjs/config';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import * as bcrypt from 'bcrypt';
import * as crypto from 'crypto';
import { User } from '@/entities/user.entity';
import { UserAccount } from '@/entities/user-account.entity';
import {
@@ -16,6 +17,7 @@ import {
LoginByPasswordDto,
RegisterDto,
WechatLoginDto,
WechatPhoneLoginDto,
} from './dto/auth.dto';
@Injectable()
@@ -84,6 +86,66 @@ export class AuthService {
}
}
async loginByWechatPhone(dto: WechatPhoneLoginDto) {
try {
// 1. 调用微信接口获取 session_key
const wechatData = await this.getWechatOpenid(dto.code);
if (!wechatData.session_key) {
throw new BadRequestException('获取微信会话失败');
}
// 2. 解密手机号
const phoneData = this.decryptWechatData(
dto.encryptedData,
dto.iv,
wechatData.session_key,
);
if (!phoneData || !phoneData.phoneNumber) {
throw new BadRequestException('解密手机号失败');
}
const phone = phoneData.phoneNumber;
this.logger.log(`微信手机号登录: phone=${phone}, openid=${wechatData.openid}`);
// 3. 查找或创建用户
let user = await this.userRepo.findOne({ where: { phone } });
if (!user) {
// 自动创建新用户
user = this.userRepo.create({
phone,
wechatOpenid: wechatData.openid,
wechatUnionid: wechatData.unionid,
nickname: `用户${phone.slice(-4)}`,
});
await this.userRepo.save(user);
this.logger.log(`微信手机号自动注册: phone=${phone}`);
// 自动创建用户账户
await this.createUserAccount(user.id);
} else {
// 更新微信信息
if (wechatData.openid && !user.wechatOpenid) {
user.wechatOpenid = wechatData.openid;
}
if (wechatData.unionid && !user.wechatUnionid) {
user.wechatUnionid = wechatData.unionid;
}
user.lastLoginAt = new Date();
await this.userRepo.save(user);
}
return this.generateToken(user);
} catch (error) {
this.logger.error('微信手机号登录失败', error);
throw new BadRequestException(
error.message || '微信手机号登录失败,请重试',
);
}
}
async register(dto: RegisterDto) {
const existing = await this.userRepo.findOne({
where: { phone: dto.phone },
@@ -146,7 +208,7 @@ export class AuthService {
this.logger.log(`[DEV] 模拟微信登录: code=${code}`);
return {
openid: `dev_openid_${Date.now()}`,
session_key: 'dev_session_key',
session_key: 'dev_session_key_1234567890abcdef',
unionid: `dev_unionid_${Date.now()}`,
};
}
@@ -181,6 +243,46 @@ export class AuthService {
}
}
private decryptWechatData(
encryptedData: string,
iv: string,
sessionKey: string,
): any {
try {
// 开发模式:返回模拟数据
const isDev = process.env.NODE_ENV === 'development';
if (isDev) {
this.logger.log('[DEV] 模拟解密微信手机号');
return {
phoneNumber: '13800138000',
purePhoneNumber: '13800138000',
countryCode: '86',
};
}
// 生产模式:解密数据
const sessionKeyBuffer = Buffer.from(sessionKey, 'base64');
const encryptedDataBuffer = Buffer.from(encryptedData, 'base64');
const ivBuffer = Buffer.from(iv, 'base64');
const decipher = crypto.createDecipheriv(
'aes-128-cbc',
sessionKeyBuffer,
ivBuffer,
);
decipher.setAutoPadding(true);
let decrypted = decipher.update(encryptedDataBuffer, undefined, 'utf8');
decrypted += decipher.final('utf8');
const result = JSON.parse(decrypted);
return result;
} catch (error) {
this.logger.error('解密微信数据失败', error);
throw new BadRequestException('解密失败');
}
}
private async validateByPhone(phone: string, code: string): Promise<User> {
const isDev = process.env.NODE_ENV === 'development';
const devCode = '123456';
@@ -37,6 +37,20 @@ export class WechatLoginDto {
avatar?: string;
}
export class WechatPhoneLoginDto {
@IsString()
@IsNotEmpty({ message: 'code不能为空' })
code: string;
@IsString()
@IsNotEmpty({ message: 'encryptedData不能为空' })
encryptedData: string;
@IsString()
@IsNotEmpty({ message: 'iv不能为空' })
iv: string;
}
export class RegisterDto {
@IsString()
@IsNotEmpty({ message: '手机号不能为空' })
@@ -80,6 +80,13 @@ export class OrderService {
const roomAmount = totalRoomPrice * roomCount;
// 计算平台服务费(基于房费)
const serviceFeeRate = await this.configService.getServiceFeeRate();
const serviceFee = Math.round(roomAmount * serviceFeeRate * 100) / 100;
// 订单总额 = 房费 + 服务费
const totalAmount = roomAmount + serviceFee;
// 处理优惠券
let couponDiscount = 0;
let userCouponId: number | null = null;
@@ -99,18 +106,16 @@ export class OrderService {
throw new BadRequestException('优惠券不可用');
}
// 计算优惠金额
// 计算优惠金额(优惠券只能抵扣房费部分,不能抵扣服务费)
couponDiscount = this.couponService.calculateDiscount(userCoupon.coupon, roomAmount);
userCouponId = userCoupon.id;
}
const totalAmount = roomAmount;
// 实付金额 = 订单总额 - 优惠券折扣
const payAmount = totalAmount - couponDiscount;
// 计算软件服务费和商家收入
const serviceFeeRate = await this.configService.getServiceFeeRate();
const serviceFee = Math.round(payAmount * serviceFeeRate * 100) / 100;
const merchantIncome = Math.round((payAmount - serviceFee) * 100) / 100;
// 商家收入 = 房费 - 优惠券折扣
const merchantIncome = Math.round((roomAmount - couponDiscount) * 100) / 100;
const now = new Date();
const orderNo = [
@@ -19,4 +19,14 @@ export class RoomController {
async findOne(@Param('id') id: number) {
return this.roomService.findById(id);
}
@Get(':id/calendar')
@ApiOperation({ summary: '获取房源日历' })
async getCalendar(
@Param('id') id: number,
@Query('startDate') startDate: string,
@Query('endDate') endDate: string,
) {
return this.roomService.getRoomCalendar(id, startDate, endDate);
}
}
@@ -1,6 +1,6 @@
import { Injectable, NotFoundException } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository, Between, LessThanOrEqual, MoreThanOrEqual } from 'typeorm';
import { Repository, LessThan, MoreThanOrEqual } from 'typeorm';
import { Room } from '@/entities/room.entity';
import { RoomCalendar } from '@/entities/room-calendar.entity';
import { QueryAvailableRoomsDto } from './dto/room.dto';
@@ -23,6 +23,18 @@ export class RoomService {
return room;
}
async getRoomCalendar(roomId: number, startDate: string, endDate: string) {
const calendar = await this.calendarRepo.find({
where: {
roomId: Number(roomId),
date: MoreThanOrEqual(startDate) && LessThan(endDate),
},
order: { date: 'ASC' },
});
return calendar;
}
async findAvailable(query: QueryAvailableRoomsDto) {
const {
merchantId,
@@ -37,7 +49,6 @@ export class RoomService {
const qb = this.roomRepo
.createQueryBuilder('room')
.leftJoinAndSelect('room.merchant', 'merchant')
.where('room.status = :status', { status: 'on_sale' })
.andWhere('room.audit_status = :auditStatus', { auditStatus: 'approved' });
@@ -51,22 +62,51 @@ export class RoomService {
qb.andWhere('room.max_guests >= :totalGuests', { totalGuests });
// 如果提供了日期,检查可用性
let roomPriceMap = new Map<number, number>(); // 存储每个房间的实际价格
if (checkIn && checkOut) {
// 查询在该日期范围内有库存的房间
const availableRoomIds = await this.calendarRepo
// 计算需要的天数
const checkInDate = new Date(checkIn);
const checkOutDate = new Date(checkOut);
const daysDiff = Math.ceil((checkOutDate.getTime() - checkInDate.getTime()) / (1000 * 60 * 60 * 24));
// 查询日历记录
const calendarRecords = await this.calendarRepo
.createQueryBuilder('cal')
.select('DISTINCT cal.room_id', 'roomId')
.where('cal.date >= :checkIn', { checkIn })
.andWhere('cal.date < :checkOut', { checkOut })
.andWhere('cal.status = :status', { status: 'available' })
.andWhere('(cal.stock - cal.sold) >= :roomCount', { roomCount })
.groupBy('cal.room_id')
.having('COUNT(*) = DATEDIFF(:checkOut, :checkIn)', { checkIn, checkOut })
.getRawMany();
.getMany();
// 按房间ID分组,检查每个房间是否在所有日期都有足够库存,并计算总价
const roomAvailability = new Map<number, number>();
const roomTotalPrice = new Map<number, number>();
for (const record of calendarRecords) {
const availableStock = record.stock - record.sold;
if (availableStock >= roomCount) {
const count = roomAvailability.get(record.roomId) || 0;
roomAvailability.set(record.roomId, count + 1);
// 累加价格
const totalPrice = roomTotalPrice.get(record.roomId) || 0;
roomTotalPrice.set(record.roomId, totalPrice + Number(record.price));
}
}
// 筛选出在所有日期都有库存的房间
const availableRoomIds = Array.from(roomAvailability.entries())
.filter(([_, count]) => count >= daysDiff)
.map(([roomId, _]) => roomId);
if (availableRoomIds.length > 0) {
const roomIds = availableRoomIds.map(item => item.roomId);
qb.andWhere('room.id IN (:...roomIds)', { roomIds });
qb.andWhere('room.id IN (:...roomIds)', { roomIds: availableRoomIds });
// 计算每个房间的平均价格(用于显示)
availableRoomIds.forEach(roomId => {
const totalPrice = roomTotalPrice.get(roomId) || 0;
roomPriceMap.set(roomId, totalPrice / daysDiff);
});
} else {
// 没有可用房间,返回空结果
return { list: [], total: 0, page, pageSize };
@@ -78,6 +118,35 @@ export class RoomService {
const [list, total] = await qb.getManyAndCount();
// 批量加载 merchant 关系
if (list.length > 0) {
const merchantIds = [...new Set(list.map(room => room.merchantId))];
const merchants = await this.roomRepo.manager
.createQueryBuilder()
.from('merchants', 'merchant')
.where('merchant.id IN (:...merchantIds)', { merchantIds })
.getMany();
const merchantMap = new Map(merchants.map(m => [m.id, m]));
// 转换为普通对象并添加 currentPrice
const result = list.map(room => {
const roomData = {
...room,
merchant: merchantMap.get(room.merchantId),
};
// 如果有日历价格,添加 currentPrice 字段
if (roomPriceMap.has(room.id)) {
roomData['currentPrice'] = roomPriceMap.get(room.id);
}
return roomData;
});
return { list: result, total, page, pageSize };
}
return { list, total, page, pageSize };
}
}
@@ -87,6 +87,18 @@ export class QueryCouponDto {
@IsOptional()
@IsEnum(['platform', 'merchant', 'room'])
scope?: 'platform' | 'merchant' | 'room';
@ApiProperty({ description: '商家ID', required: false })
@IsOptional()
@Type(() => Number)
@IsNumber()
merchantId?: number;
@ApiProperty({ description: '房源ID', required: false })
@IsOptional()
@Type(() => Number)
@IsNumber()
roomId?: number;
}
export class ReceiveCouponDto {