feat: 迭代
This commit is contained in:
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user