This commit is contained in:
2026-04-23 18:51:49 +08:00
parent 3284321919
commit 6be9d4afb7
74 changed files with 7660 additions and 688 deletions
+1
View File
@@ -27,6 +27,7 @@
"@nestjs/jwt": "^11.0.0",
"@nestjs/passport": "^11.0.5",
"@nestjs/platform-express": "^11.0.1",
"@nestjs/schedule": "^6.1.3",
"@nestjs/swagger": "^11.0.3",
"@nestjs/typeorm": "^11.0.0",
"bcrypt": "^6.0.0",
+10
View File
@@ -1,6 +1,7 @@
import { Module } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config';
import { TypeOrmModule } from '@nestjs/typeorm';
import { ScheduleModule } from '@nestjs/schedule';
import databaseConfig from './config/database.config';
import jwtConfig from './config/jwt.config';
import redisConfig from './config/redis.config';
@@ -12,9 +13,18 @@ import { RoomModule } from './modules/room/room.module';
import { RoomCalendarModule } from './modules/room-calendar/room-calendar.module';
import { OrderModule } from './modules/order/order.module';
import { AdminAuthModule } from './modules/admin-auth/admin-auth.module';
import { ReviewModule } from './modules/review/review.module';
import { FinanceModule } from './modules/finance/finance.module';
import { ActivityModule } from './modules/activity/activity.module';
import { ScheduleModule as TaskScheduleModule } from './schedule/schedule.module';
@Module({
imports: [
ScheduleModule.forRoot(),
TaskScheduleModule,
FinanceModule,
ReviewModule,
ActivityModule,
ConfigModule.forRoot({
isGlobal: true,
load: [databaseConfig, jwtConfig, redisConfig],
@@ -62,6 +62,18 @@ export class Merchant {
@Column({ type: 'decimal', precision: 10, scale: 2, unsigned: true, default: 0, comment: '保证金' })
deposit: number;
@Column({ name: 'wallet_balance', type: 'decimal', precision: 10, scale: 2, unsigned: true, default: 0, comment: '待提现余额' })
walletBalance: number;
@Column({ name: 'bank_name', length: 100, default: '', comment: '开户银行' })
bankName: string;
@Column({ name: 'bank_account', length: 50, default: '', comment: '银行账号' })
bankAccount: string;
@Column({ name: 'account_name', length: 50, default: '', comment: '账户名' })
accountName: string;
@Index()
@Column({ type: 'decimal', precision: 2, scale: 1, unsigned: true, default: 5.0, comment: '评分' })
rating: number;
@@ -0,0 +1,45 @@
import { Entity, PrimaryGeneratedColumn, Column, CreateDateColumn, UpdateDateColumn, Index } from 'typeorm';
export type ActivityType = 'invite_cashback';
@Entity('mkt_activities')
export class MktActivity {
@PrimaryGeneratedColumn({ type: 'bigint', unsigned: true })
id: number;
@Column({ length: 100, comment: '活动名称' })
name: string;
@Index()
@Column({
type: 'enum',
enum: ['invite_cashback'],
default: 'invite_cashback',
comment: '活动类型',
})
type: ActivityType;
@Column({ type: 'boolean', default: true, comment: '是否启用' })
enabled: boolean;
@Column({ type: 'json', comment: '活动配置' })
config: {
firstOrderRate: number;
secondOrderRate: number;
minCashback: number;
maxCashback: number;
withdrawThreshold: number;
};
@Column({ name: 'start_time', type: 'datetime', nullable: true, comment: '活动开始时间' })
startTime: Date;
@Column({ name: 'end_time', type: 'datetime', nullable: true, comment: '活动结束时间' })
endTime: Date;
@CreateDateColumn({ comment: '创建时间' })
createdAt: Date;
@UpdateDateColumn({ comment: '更新时间' })
updatedAt: Date;
}
@@ -0,0 +1,74 @@
import { Entity, PrimaryGeneratedColumn, Column, CreateDateColumn, Index, ManyToOne, JoinColumn } from 'typeorm';
import { User } from './user.entity';
import { Order } from './order.entity';
import { MktActivity } from './mkt-activity.entity';
export type CashbackStatus = 'pending' | 'settled' | 'cancelled';
@Entity('mkt_cashbacks')
export class MktCashback {
@PrimaryGeneratedColumn({ type: 'bigint', unsigned: true })
id: number;
@Index()
@Column({ name: 'activity_id', type: 'bigint', unsigned: true, comment: '活动ID' })
activityId: number;
@Index()
@Column({ name: 'inviter_id', type: 'bigint', unsigned: true, comment: '邀请人用户ID' })
inviterId: number;
@Index()
@Column({ name: 'invitee_id', type: 'bigint', unsigned: true, comment: '被邀请人用户ID' })
inviteeId: number;
@Index()
@Column({ name: 'order_id', type: 'bigint', unsigned: true, comment: '关联订单ID' })
orderId: number;
@Column({ name: 'order_no', length: 32, comment: '订单号' })
orderNo: string;
@Column({ name: 'order_amount', type: 'decimal', precision: 10, scale: 2, unsigned: true, comment: '订单金额' })
orderAmount: number;
@Column({ name: 'order_index', type: 'tinyint', unsigned: true, comment: '被邀请人第几单(1/2)' })
orderIndex: number;
@Column({ type: 'decimal', precision: 5, scale: 4, unsigned: true, comment: '返现比例' })
rate: number;
@Column({ type: 'decimal', precision: 10, scale: 2, unsigned: true, comment: '返现金额' })
amount: number;
@Index()
@Column({
type: 'enum',
enum: ['pending', 'settled', 'cancelled'],
default: 'pending',
comment: '状态',
})
status: CashbackStatus;
@Column({ name: 'settled_at', type: 'datetime', nullable: true, comment: '到账时间' })
settledAt: Date;
@ManyToOne(() => MktActivity)
@JoinColumn({ name: 'activity_id' })
activity: MktActivity;
@ManyToOne(() => User)
@JoinColumn({ name: 'inviter_id' })
inviter: User;
@ManyToOne(() => User)
@JoinColumn({ name: 'invitee_id' })
invitee: User;
@ManyToOne(() => Order)
@JoinColumn({ name: 'order_id' })
order: Order;
@CreateDateColumn({ comment: '创建时间' })
createdAt: Date;
}
@@ -0,0 +1,42 @@
import { Entity, PrimaryGeneratedColumn, Column, CreateDateColumn, Index, ManyToOne, JoinColumn } from 'typeorm';
import { User } from './user.entity';
import { MktActivity } from './mkt-activity.entity';
@Entity('mkt_invitations')
export class MktInvitation {
@PrimaryGeneratedColumn({ type: 'bigint', unsigned: true })
id: number;
@Index()
@Column({ name: 'activity_id', type: 'bigint', unsigned: true, comment: '活动ID' })
activityId: number;
@Index()
@Column({ name: 'inviter_id', type: 'bigint', unsigned: true, comment: '邀请人用户ID' })
inviterId: number;
@Index()
@Column({ name: 'invitee_id', type: 'bigint', unsigned: true, unique: true, comment: '被邀请人用户ID' })
inviteeId: number;
@Column({ name: 'invite_code', length: 32, comment: '邀请码' })
inviteCode: string;
@Column({ length: 100, nullable: true, comment: '小程序scene参数' })
scene: string;
@ManyToOne(() => MktActivity)
@JoinColumn({ name: 'activity_id' })
activity: MktActivity;
@ManyToOne(() => User)
@JoinColumn({ name: 'inviter_id' })
inviter: User;
@ManyToOne(() => User)
@JoinColumn({ name: 'invitee_id' })
invitee: User;
@CreateDateColumn({ comment: '邀请时间' })
createdAt: Date;
}
@@ -0,0 +1,57 @@
import { Entity, PrimaryGeneratedColumn, Column, CreateDateColumn, UpdateDateColumn, Index, ManyToOne, JoinColumn } from 'typeorm';
import { User } from './user.entity';
import { MktActivity } from './mkt-activity.entity';
export type InviteWithdrawalStatus = 'pending' | 'approved' | 'rejected' | 'paid';
@Entity('mkt_invite_withdrawals')
export class MktInviteWithdrawal {
@PrimaryGeneratedColumn({ type: 'bigint', unsigned: true })
id: number;
@Index()
@Column({ name: 'activity_id', type: 'bigint', unsigned: true, comment: '活动ID' })
activityId: number;
@Index()
@Column({ name: 'user_id', type: 'bigint', unsigned: true, comment: '用户ID' })
userId: number;
@Column({ type: 'decimal', precision: 10, scale: 2, unsigned: true, comment: '提现金额' })
amount: number;
@Index()
@Column({
type: 'enum',
enum: ['pending', 'approved', 'rejected', 'paid'],
default: 'pending',
comment: '状态',
})
status: InviteWithdrawalStatus;
@Column({ name: 'reject_reason', length: 500, nullable: true, comment: '拒绝原因' })
rejectReason: string;
@Column({ name: 'reviewer_id', type: 'bigint', unsigned: true, nullable: true, comment: '审核人ID' })
reviewerId: number;
@Column({ name: 'reviewed_at', type: 'datetime', nullable: true, comment: '审核时间' })
reviewedAt: Date;
@Column({ name: 'paid_at', type: 'datetime', nullable: true, comment: '打款时间' })
paidAt: Date;
@ManyToOne(() => MktActivity)
@JoinColumn({ name: 'activity_id' })
activity: MktActivity;
@ManyToOne(() => User)
@JoinColumn({ name: 'user_id' })
user: User;
@CreateDateColumn({ comment: '申请时间' })
createdAt: Date;
@UpdateDateColumn({ comment: '更新时间' })
updatedAt: Date;
}
@@ -0,0 +1,49 @@
import { Entity, PrimaryGeneratedColumn, Column, CreateDateColumn, UpdateDateColumn, Index, ManyToOne, JoinColumn } from 'typeorm';
import { User } from './user.entity';
import { MktActivity } from './mkt-activity.entity';
@Entity('mkt_user_invite_stats')
export class MktUserInviteStats {
@PrimaryGeneratedColumn({ type: 'bigint', unsigned: true })
id: number;
@Index()
@Column({ name: 'activity_id', type: 'bigint', unsigned: true, comment: '活动ID' })
activityId: number;
@Index()
@Column({ name: 'user_id', type: 'bigint', unsigned: true, unique: true, comment: '用户ID' })
userId: number;
@Column({ name: 'invite_code', length: 32, unique: true, comment: '专属邀请码' })
inviteCode: string;
@Column({ name: 'total_invites', type: 'int', unsigned: true, default: 0, comment: '累计邀请人数' })
totalInvites: number;
@Column({ name: 'total_orders', type: 'int', unsigned: true, default: 0, comment: '累计下单人数' })
totalOrders: number;
@Column({ name: 'total_cashback', type: 'decimal', precision: 10, scale: 2, unsigned: true, default: 0, comment: '累计返现金额' })
totalCashback: number;
@Column({ name: 'available_balance', type: 'decimal', precision: 10, scale: 2, unsigned: true, default: 0, comment: '可提现余额' })
availableBalance: number;
@Column({ name: 'withdrawn_amount', type: 'decimal', precision: 10, scale: 2, unsigned: true, default: 0, comment: '已提现金额' })
withdrawnAmount: number;
@ManyToOne(() => MktActivity)
@JoinColumn({ name: 'activity_id' })
activity: MktActivity;
@ManyToOne(() => User)
@JoinColumn({ name: 'user_id' })
user: User;
@CreateDateColumn({ comment: '创建时间' })
createdAt: Date;
@UpdateDateColumn({ comment: '更新时间' })
updatedAt: Date;
}
-3
View File
@@ -44,9 +44,6 @@ export class Order {
@Column({ name: 'room_amount', type: 'decimal', precision: 10, scale: 2, unsigned: true, comment: '房费总额' })
roomAmount: number;
@Column({ name: 'service_fee', type: 'decimal', precision: 10, scale: 2, unsigned: true, default: 0, comment: '服务费' })
serviceFee: number;
@Column({ name: 'coupon_discount', type: 'decimal', precision: 10, scale: 2, unsigned: true, default: 0, comment: '优惠券抵扣' })
couponDiscount: number;
+14 -3
View File
@@ -1,10 +1,18 @@
import { Entity, PrimaryGeneratedColumn, Column, CreateDateColumn, UpdateDateColumn, Index } from 'typeorm';
import { Entity, PrimaryGeneratedColumn, Column, CreateDateColumn, UpdateDateColumn, Index, ManyToOne, JoinColumn } from 'typeorm';
import { Order } from './order.entity';
import { User } from './user.entity';
import { Merchant } from './merchant.entity';
import { Room } from './room.entity';
@Entity('reviews')
export class Review {
@PrimaryGeneratedColumn({ type: 'bigint', unsigned: true })
id: number;
@ManyToOne(() => Order)
@JoinColumn({ name: 'order_id' })
order: Order;
@Column({ name: 'order_id', type: 'bigint', unsigned: true, unique: true, comment: '订单ID' })
orderId: number;
@@ -38,8 +46,11 @@ export class Review {
@Column({ name: 'is_anonymous', type: 'boolean', default: false, comment: '是否匿名' })
isAnonymous: boolean;
@Column({ type: 'enum', enum: ['visible', 'hidden'], default: 'visible', comment: '状态' })
status: 'visible' | 'hidden';
@Column({ name: 'audit_status', type: 'enum', enum: ['pending', 'approved', 'rejected'], default: 'pending', comment: '审核状态' })
auditStatus: 'pending' | 'approved' | 'rejected';
@Column({ name: 'audit_reject_reason', type: 'varchar', length: 500, nullable: true, comment: '审核拒绝原因' })
auditRejectReason: string;
@CreateDateColumn({ comment: '创建时间' })
createdAt: Date;
@@ -0,0 +1,29 @@
import { Entity, PrimaryGeneratedColumn, Column, CreateDateColumn, ManyToOne, JoinColumn, Index } from 'typeorm';
import { Settlement } from './settlement.entity';
@Entity('settlement_items')
export class SettlementItem {
@PrimaryGeneratedColumn({ type: 'bigint', unsigned: true })
id: number;
@Index()
@ManyToOne(() => Settlement, s => s.items)
@JoinColumn({ name: 'settlement_id' })
settlement: Settlement;
@Column({ name: 'settlement_id', type: 'bigint', unsigned: true, comment: '对账单ID' })
settlementId: number;
@Index()
@Column({ name: 'order_id', type: 'bigint', unsigned: true, comment: '订单ID' })
orderId: number;
@Column({ name: 'order_no', type: 'varchar', length: 32, comment: '订单号' })
orderNo: string;
@Column({ name: 'order_amount', type: 'decimal', precision: 10, scale: 2, unsigned: true, comment: '订单金额' })
orderAmount: number;
@CreateDateColumn({ comment: '创建时间' })
createdAt: Date;
}
@@ -0,0 +1,64 @@
import { Entity, PrimaryGeneratedColumn, Column, CreateDateColumn, UpdateDateColumn, Index, ManyToOne, JoinColumn, OneToMany } from 'typeorm';
import { Merchant } from './merchant.entity';
import { SettlementItem } from './settlement-item.entity';
@Entity('settlements')
export class Settlement {
@PrimaryGeneratedColumn({ type: 'bigint', unsigned: true })
id: number;
@Index()
@ManyToOne(() => Merchant)
@JoinColumn({ name: 'merchant_id' })
merchant: Merchant;
@Column({ name: 'merchant_id', type: 'bigint', unsigned: true, comment: '商家ID' })
merchantId: number;
@Column({ name: 'settlement_no', type: 'varchar', length: 32, unique: true, comment: '对账单号' })
settlementNo: string;
@Index()
@Column({ name: 'period_start', type: 'date', comment: '周期开始日期(周日)' })
periodStart: string;
@Column({ name: 'period_end', type: 'date', comment: '周期结束日期(周六)' })
periodEnd: string;
@Column({ name: 'order_count', type: 'int', unsigned: true, default: 0, comment: '订单数量' })
orderCount: number;
@Column({ name: 'order_amount', type: 'decimal', precision: 10, scale: 2, unsigned: true, default: 0, comment: '订单总金额' })
orderAmount: number;
@Column({ name: 'commission_rate', type: 'decimal', precision: 5, scale: 4, unsigned: true, default: 0, comment: '佣金比例' })
commissionRate: number;
@Column({ name: 'commission_amount', type: 'decimal', precision: 10, scale: 2, unsigned: true, default: 0, comment: '佣金金额' })
commissionAmount: number;
@Column({ name: 'settlement_amount', type: 'decimal', precision: 10, scale: 2, unsigned: true, default: 0, comment: '结算金额(订单金额-佣金)' })
settlementAmount: number;
@Index()
@Column({ type: 'enum', enum: ['pending', 'approved', 'rejected'], default: 'pending', comment: '状态' })
status: 'pending' | 'approved' | 'rejected';
@Column({ name: 'reject_reason', type: 'varchar', length: 500, nullable: true, comment: '拒绝原因' })
rejectReason: string;
@Column({ name: 'reviewer_id', type: 'bigint', unsigned: true, nullable: true, comment: '审核人ID' })
reviewerId: number;
@Column({ name: 'reviewed_at', type: 'datetime', nullable: true, comment: '审核时间' })
reviewedAt: Date;
@OneToMany(() => SettlementItem, item => item.settlement)
items: SettlementItem[];
@CreateDateColumn({ comment: '创建时间' })
createdAt: Date;
@UpdateDateColumn({ comment: '更新时间' })
updatedAt: Date;
}
@@ -0,0 +1,62 @@
import { Entity, PrimaryGeneratedColumn, Column, CreateDateColumn, UpdateDateColumn, Index, ManyToOne, JoinColumn } from 'typeorm';
import { Merchant } from './merchant.entity';
@Entity('withdrawals')
export class Withdrawal {
@PrimaryGeneratedColumn({ type: 'bigint', unsigned: true })
id: number;
@Index()
@ManyToOne(() => Merchant)
@JoinColumn({ name: 'merchant_id' })
merchant: Merchant;
@Column({ name: 'merchant_id', type: 'bigint', unsigned: true, comment: '商家ID' })
merchantId: number;
@Column({ name: 'settlement_ids', type: 'json', nullable: true, comment: '关联对账单ID列表' })
settlementIds: number[];
@Column({ type: 'decimal', precision: 10, scale: 2, unsigned: true, comment: '提现金额' })
amount: number;
@Column({ type: 'decimal', precision: 10, scale: 2, unsigned: true, default: 0, comment: '手续费' })
fee: number;
@Column({ name: 'commission_amount', type: 'decimal', precision: 10, scale: 2, unsigned: true, default: 0, comment: '平台佣金' })
commissionAmount: number;
@Column({ name: 'actual_amount', type: 'decimal', precision: 10, scale: 2, unsigned: true, comment: '实际到账金额' })
actualAmount: number;
@Column({ name: 'bank_name', type: 'varchar', length: 100, comment: '开户银行' })
bankName: string;
@Column({ name: 'bank_account', type: 'varchar', length: 50, comment: '银行账号' })
bankAccount: string;
@Column({ name: 'account_name', type: 'varchar', length: 50, comment: '账户名' })
accountName: string;
@Index()
@Column({ type: 'enum', enum: ['pending', 'approved', 'rejected', 'paid'], default: 'pending', comment: '状态' })
status: 'pending' | 'approved' | 'rejected' | 'paid';
@Column({ name: 'reviewer_id', type: 'bigint', unsigned: true, nullable: true, comment: '审核人ID' })
reviewerId: number;
@Column({ name: 'reviewed_at', type: 'datetime', nullable: true, comment: '审核时间' })
reviewedAt: Date;
@Column({ name: 'reject_reason', type: 'varchar', length: 500, nullable: true, comment: '拒绝原因' })
rejectReason: string;
@Column({ name: 'paid_at', type: 'datetime', nullable: true, comment: '打款时间' })
paidAt: Date;
@CreateDateColumn({ comment: '创建时间' })
createdAt: Date;
@UpdateDateColumn({ comment: '更新时间' })
updatedAt: Date;
}
@@ -0,0 +1,172 @@
import {
Controller,
Get,
Post,
Put,
Body,
Query,
Param,
UseGuards,
Request,
ParseIntPipe,
} from '@nestjs/common';
import { ApiTags, ApiOperation, ApiBearerAuth, ApiParam } from '@nestjs/swagger';
import { ActivityService } from './activity.service';
import {
CreateActivityDto,
UpdateActivityDto,
QueryActivityDto,
BindInvitationDto,
CreateInviteWithdrawalDto,
QueryInviteRecordsDto,
RejectInviteWithdrawalDto,
QueryAdminInviteWithdrawalsDto,
QueryAdminCashbacksDto,
} from './dto/activity.dto';
import { JwtAuthGuard, RolesGuard, Roles, CurrentUser } from '@/common';
// ===== 用户端邀请接口 =====
@ApiTags('用户端-邀请返现')
@ApiBearerAuth()
@UseGuards(JwtAuthGuard)
@Controller('user/activity/invite')
export class UserInviteController {
constructor(private readonly activityService: ActivityService) {}
@Get('stats')
@ApiOperation({ summary: '获取我的邀请统计' })
async getStats(@CurrentUser('userId') userId: number) {
return this.activityService.getUserStats(userId);
}
@Post('bind')
@ApiOperation({ summary: '绑定邀请关系' })
async bind(@CurrentUser('userId') userId: number, @Body() dto: BindInvitationDto) {
return this.activityService.bindInvitation(userId, dto);
}
@Get('records')
@ApiOperation({ summary: '邀请记录列表' })
async getRecords(@CurrentUser('userId') userId: number, @Query() dto: QueryInviteRecordsDto) {
return this.activityService.getInviteRecords(userId, dto);
}
@Get('cashbacks')
@ApiOperation({ summary: '返现记录列表' })
async getCashbacks(@CurrentUser('userId') userId: number, @Query() dto: QueryInviteRecordsDto) {
return this.activityService.getCashbackRecords(userId, dto);
}
@Post('withdraw')
@ApiOperation({ summary: '申请提现' })
async createWithdrawal(
@CurrentUser('userId') userId: number,
@Body() dto: CreateInviteWithdrawalDto,
) {
return this.activityService.createWithdrawal(userId, dto);
}
@Get('withdrawals')
@ApiOperation({ summary: '提现记录列表' })
async getWithdrawals(
@CurrentUser('userId') userId: number,
@Query() dto: QueryInviteRecordsDto,
) {
return this.activityService.getWithdrawalRecords(userId, dto);
}
}
// ===== 管理端活动接口 =====
@ApiTags('管理端-活动管理')
@ApiBearerAuth()
@UseGuards(JwtAuthGuard, RolesGuard)
@Roles('admin')
@Controller('admin/activity')
export class AdminActivityController {
constructor(private readonly activityService: ActivityService) {}
@Get('list')
@ApiOperation({ summary: '活动列表' })
async list(@Query() dto: QueryActivityDto) {
return this.activityService.getActivities(dto);
}
@Get(':id')
@ApiOperation({ summary: '活动详情' })
@ApiParam({ name: 'id', description: '活动ID' })
async getById(@Param('id', ParseIntPipe) id: number) {
return this.activityService.getActivityById(id);
}
@Post('create')
@ApiOperation({ summary: '创建活动' })
async create(@Body() dto: CreateActivityDto) {
return this.activityService.createActivity(dto);
}
@Put(':id/update')
@ApiOperation({ summary: '编辑活动' })
@ApiParam({ name: 'id', description: '活动ID' })
async update(@Param('id', ParseIntPipe) id: number, @Body() dto: UpdateActivityDto) {
return this.activityService.updateActivity(id, dto);
}
@Put(':id/toggle')
@ApiOperation({ summary: '启用/停用活动' })
@ApiParam({ name: 'id', description: '活动ID' })
async toggle(@Param('id', ParseIntPipe) id: number) {
return this.activityService.toggleActivity(id);
}
@Get('invite/stats')
@ApiOperation({ summary: '邀请数据总览' })
async getStats() {
return this.activityService.getAdminStats();
}
@Get('invite/records')
@ApiOperation({ summary: '邀请记录列表' })
async getRecords(@Query() dto: QueryInviteRecordsDto) {
return this.activityService.getAdminInvitations(dto);
}
@Get('invite/cashbacks')
@ApiOperation({ summary: '返现记录列表' })
async getCashbacks(@Query() dto: QueryAdminCashbacksDto) {
return this.activityService.getAdminCashbacks(dto);
}
@Get('invite/withdrawals')
@ApiOperation({ summary: '提现审核列表' })
async getWithdrawals(@Query() dto: QueryAdminInviteWithdrawalsDto) {
return this.activityService.getAdminWithdrawals(dto);
}
@Put('invite/withdrawals/:id/approve')
@ApiOperation({ summary: '审核通过' })
@ApiParam({ name: 'id', description: '提现申请ID' })
async approve(
@Param('id', ParseIntPipe) id: number,
@CurrentUser('userId') reviewerId: number,
) {
return this.activityService.approveWithdrawal(id, reviewerId);
}
@Put('invite/withdrawals/:id/reject')
@ApiOperation({ summary: '审核拒绝' })
@ApiParam({ name: 'id', description: '提现申请ID' })
async reject(
@Param('id', ParseIntPipe) id: number,
@CurrentUser('userId') reviewerId: number,
@Body() dto: RejectInviteWithdrawalDto,
) {
return this.activityService.rejectWithdrawal(id, reviewerId, dto);
}
@Put('invite/withdrawals/:id/pay')
@ApiOperation({ summary: '确认打款' })
@ApiParam({ name: 'id', description: '提现申请ID' })
async pay(@Param('id', ParseIntPipe) id: number) {
return this.activityService.payWithdrawal(id);
}
}
@@ -0,0 +1,29 @@
import { Module, forwardRef } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { ActivityService } from './activity.service';
import { UserInviteController, AdminActivityController } from './activity.controller';
import { MktActivity } from '@/entities/mkt-activity.entity';
import { MktInvitation } from '@/entities/mkt-invitation.entity';
import { MktCashback } from '@/entities/mkt-cashback.entity';
import { MktUserInviteStats } from '@/entities/mkt-user-invite-stats.entity';
import { MktInviteWithdrawal } from '@/entities/mkt-invite-withdrawal.entity';
import { Order } from '@/entities/order.entity';
import { User } from '@/entities/user.entity';
@Module({
imports: [
TypeOrmModule.forFeature([
MktActivity,
MktInvitation,
MktCashback,
MktUserInviteStats,
MktInviteWithdrawal,
Order,
User,
]),
],
controllers: [UserInviteController, AdminActivityController],
providers: [ActivityService],
exports: [ActivityService],
})
export class ActivityModule {}
@@ -0,0 +1,577 @@
import { Injectable, BadRequestException, NotFoundException, Logger } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository, DataSource } from 'typeorm';
import { MktActivity } from '@/entities/mkt-activity.entity';
import { MktInvitation } from '@/entities/mkt-invitation.entity';
import { MktCashback } from '@/entities/mkt-cashback.entity';
import { MktUserInviteStats } from '@/entities/mkt-user-invite-stats.entity';
import { MktInviteWithdrawal } from '@/entities/mkt-invite-withdrawal.entity';
import { Order } from '@/entities/order.entity';
import { User } from '@/entities/user.entity';
import {
CreateActivityDto,
UpdateActivityDto,
QueryActivityDto,
BindInvitationDto,
CreateInviteWithdrawalDto,
QueryInviteRecordsDto,
RejectInviteWithdrawalDto,
QueryAdminInviteWithdrawalsDto,
QueryAdminCashbacksDto,
} from './dto/activity.dto';
const ALPHABET = '0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz';
function encodeBase62(num: number): string {
if (num === 0) return '0';
let result = '';
while (num > 0) {
result = ALPHABET[num % 62] + result;
num = Math.floor(num / 62);
}
return result;
}
function decodeBase62(str: string): number {
let result = 0;
for (let i = 0; i < str.length; i++) {
const idx = ALPHABET.indexOf(str[i]);
if (idx === -1) return 0;
result = result * 62 + idx;
}
return result;
}
@Injectable()
export class ActivityService {
private readonly logger = new Logger(ActivityService.name);
constructor(
@InjectRepository(MktActivity)
private activityRepo: Repository<MktActivity>,
@InjectRepository(MktInvitation)
private invitationRepo: Repository<MktInvitation>,
@InjectRepository(MktCashback)
private cashbackRepo: Repository<MktCashback>,
@InjectRepository(MktUserInviteStats)
private statsRepo: Repository<MktUserInviteStats>,
@InjectRepository(MktInviteWithdrawal)
private withdrawalRepo: Repository<MktInviteWithdrawal>,
@InjectRepository(Order)
private orderRepo: Repository<Order>,
@InjectRepository(User)
private userRepo: Repository<User>,
private dataSource: DataSource,
) {}
// ===== 活动管理 =====
async createActivity(dto: CreateActivityDto) {
// 固定活动名称
const nameMap: Record<string, string> = { invite_cashback: '邀请返现' };
const activity = this.activityRepo.create({
...dto,
name: nameMap[dto.type] || dto.name,
});
return this.activityRepo.save(activity);
}
async updateActivity(id: number, dto: UpdateActivityDto) {
const activity = await this.activityRepo.findOne({ where: { id } });
if (!activity) throw new NotFoundException('活动不存在');
Object.assign(activity, dto);
return this.activityRepo.save(activity);
}
async toggleActivity(id: number) {
const activity = await this.activityRepo.findOne({ where: { id } });
if (!activity) throw new NotFoundException('活动不存在');
activity.enabled = !activity.enabled;
return this.activityRepo.save(activity);
}
async getActivities(dto: QueryActivityDto) {
const page = dto.page ?? 1;
const pageSize = dto.pageSize ?? 10;
const where: any = {};
if (dto.type) where.type = dto.type;
if (dto.enabled !== undefined) where.enabled = dto.enabled;
const [list, total] = await this.activityRepo.findAndCount({
where,
order: { createdAt: 'DESC' },
skip: (page - 1) * pageSize,
take: pageSize,
});
return { list, total, page, pageSize };
}
async getActivityById(id: number) {
const activity = await this.activityRepo.findOne({ where: { id } });
if (!activity) throw new NotFoundException('活动不存在');
return activity;
}
async getEnabledInviteCashbackActivity(): Promise<MktActivity | null> {
return this.activityRepo.findOne({
where: { type: 'invite_cashback', enabled: true },
});
}
// ===== 用户端邀请功能 =====
async getUserStats(userId: number) {
const activity = await this.getEnabledInviteCashbackActivity();
if (!activity) {
return {
inviteCode: '',
totalInvites: 0,
totalOrders: 0,
totalCashback: 0,
availableBalance: 0,
withdrawnAmount: 0,
};
}
let stats = await this.statsRepo.findOne({
where: { activityId: activity.id, userId },
});
if (!stats) {
const inviteCode = encodeBase62(userId);
stats = this.statsRepo.create({
activityId: activity.id,
userId,
inviteCode,
totalInvites: 0,
totalOrders: 0,
totalCashback: 0,
availableBalance: 0,
withdrawnAmount: 0,
});
await this.statsRepo.save(stats);
}
return stats;
}
async bindInvitation(userId: number, dto: BindInvitationDto) {
// 检查是否已被邀请
const existing = await this.invitationRepo.findOne({ where: { inviteeId: userId } });
if (existing) {
return { success: false, message: '您已有邀请人' };
}
// 解析邀请码
const inviterId = decodeBase62(dto.inviteCode);
if (!inviterId || inviterId === userId) {
return { success: false, message: '邀请码无效' };
}
// 检查邀请人是否存在
const inviter = await this.userRepo.findOne({ where: { id: inviterId } });
if (!inviter) {
return { success: false, message: '邀请人不存在' };
}
// 获取活动
const activity = await this.getEnabledInviteCashbackActivity();
if (!activity) {
return { success: false, message: '活动未开启' };
}
// 创建邀请关系
const invitation = this.invitationRepo.create({
activityId: activity.id,
inviterId,
inviteeId: userId,
inviteCode: dto.inviteCode,
});
await this.invitationRepo.save(invitation);
// 更新邀请人统计
await this.statsRepo.increment(
{ activityId: activity.id, userId: inviterId },
'totalInvites',
1,
);
// 确保邀请人有统计记录
let inviterStats = await this.statsRepo.findOne({
where: { activityId: activity.id, userId: inviterId },
});
if (!inviterStats) {
inviterStats = this.statsRepo.create({
activityId: activity.id,
userId: inviterId,
inviteCode: dto.inviteCode,
totalInvites: 1,
totalOrders: 0,
totalCashback: 0,
availableBalance: 0,
withdrawnAmount: 0,
});
await this.statsRepo.save(inviterStats);
}
return { success: true };
}
async getInviteRecords(userId: number, dto: QueryInviteRecordsDto) {
const page = dto.page ?? 1;
const pageSize = dto.pageSize ?? 20;
const activity = await this.getEnabledInviteCashbackActivity();
if (!activity) return { list: [], total: 0 };
const [list, total] = await this.invitationRepo.findAndCount({
where: { activityId: activity.id, inviterId: userId },
relations: ['invitee'],
order: { createdAt: 'DESC' },
skip: (page - 1) * pageSize,
take: pageSize,
});
// 获取每个被邀请人的订单数
const inviteeIds = list.map((item) => item.inviteeId);
const orderCounts = await this.orderRepo
.createQueryBuilder('order')
.select('order.userId', 'userId')
.addSelect('COUNT(*)', 'count')
.where('order.userId IN (:...ids)', { ids: inviteeIds })
.andWhere('order.status = :status', { status: 'completed' })
.groupBy('order.userId')
.getRawMany();
const orderCountMap = new Map(orderCounts.map((item) => [item.userId, parseInt(item.count)]));
const records = list.map((item) => ({
id: item.id,
inviteeId: item.inviteeId,
inviteeNickname: item.invitee?.nickname || '用户' + item.inviteeId,
inviteeAvatar: item.invitee?.avatar || '',
createdAt: item.createdAt,
orderCount: orderCountMap.get(item.inviteeId) || 0,
}));
return { list: records, total };
}
async getCashbackRecords(userId: number, dto: QueryInviteRecordsDto) {
const page = dto.page ?? 1;
const pageSize = dto.pageSize ?? 20;
const activity = await this.getEnabledInviteCashbackActivity();
if (!activity) return { list: [], total: 0 };
const [list, total] = await this.cashbackRepo.findAndCount({
where: { activityId: activity.id, inviterId: userId },
order: { createdAt: 'DESC' },
skip: (page - 1) * pageSize,
take: pageSize,
});
return { list, total };
}
async createWithdrawal(userId: number, dto: CreateInviteWithdrawalDto) {
const activity = await this.getEnabledInviteCashbackActivity();
if (!activity) throw new BadRequestException('活动未开启');
const stats = await this.statsRepo.findOne({
where: { activityId: activity.id, userId },
});
if (!stats || stats.availableBalance < dto.amount) {
throw new BadRequestException('余额不足');
}
const threshold = activity.config.withdrawThreshold ?? 10;
if (dto.amount < threshold) {
throw new BadRequestException(`最低提现金额为${threshold}`);
}
const queryRunner = this.dataSource.createQueryRunner();
await queryRunner.connect();
await queryRunner.startTransaction();
try {
// 创建提现申请
const withdrawal = queryRunner.manager.create(MktInviteWithdrawal, {
activityId: activity.id,
userId,
amount: dto.amount,
status: 'pending',
});
await queryRunner.manager.save(withdrawal);
// 扣减余额
stats.availableBalance = Number(stats.availableBalance) - dto.amount;
await queryRunner.manager.save(stats);
await queryRunner.commitTransaction();
return withdrawal;
} catch (err) {
await queryRunner.rollbackTransaction();
throw err;
} finally {
await queryRunner.release();
}
}
async getWithdrawalRecords(userId: number, dto: QueryInviteRecordsDto) {
const page = dto.page ?? 1;
const pageSize = dto.pageSize ?? 20;
const activity = await this.getEnabledInviteCashbackActivity();
if (!activity) return { list: [], total: 0 };
const [list, total] = await this.withdrawalRepo.findAndCount({
where: { activityId: activity.id, userId },
order: { createdAt: 'DESC' },
skip: (page - 1) * pageSize,
take: pageSize,
});
return { list, total };
}
// ===== 订单完成时触发返现 =====
async handleOrderCompleted(orderId: number) {
const order = await this.orderRepo.findOne({
where: { id: orderId },
relations: ['user'],
});
if (!order) return;
const activity = await this.getEnabledInviteCashbackActivity();
if (!activity) return;
// 查找邀请关系
const invitation = await this.invitationRepo.findOne({
where: { inviteeId: order.userId },
});
if (!invitation) return;
// 检查是否已有返现记录
const existing = await this.cashbackRepo.findOne({
where: { orderId, inviterId: invitation.inviterId },
});
if (existing) return;
// 计算该被邀请人已完成的订单数(不含退款)
const completedOrders = await this.orderRepo.count({
where: {
userId: order.userId,
status: 'completed',
},
});
// 仅前两单返现
if (completedOrders > 2) return;
const orderIndex = completedOrders;
const config = activity.config;
const rate = orderIndex === 1 ? config.firstOrderRate : config.secondOrderRate;
let amount = Number(order.payAmount) * rate;
amount = Math.max(config.minCashback, Math.min(config.maxCashback, amount));
amount = Math.round(amount * 100) / 100;
const queryRunner = this.dataSource.createQueryRunner();
await queryRunner.connect();
await queryRunner.startTransaction();
try {
// 创建返现记录
const cashback = queryRunner.manager.create(MktCashback, {
activityId: activity.id,
inviterId: invitation.inviterId,
inviteeId: order.userId,
orderId: order.id,
orderNo: order.orderNo,
orderAmount: order.payAmount,
orderIndex,
rate,
amount,
status: 'settled',
settledAt: new Date(),
});
await queryRunner.manager.save(cashback);
// 更新邀请人统计
await queryRunner.manager.increment(
MktUserInviteStats,
{ activityId: activity.id, userId: invitation.inviterId },
'totalCashback',
amount,
);
await queryRunner.manager.increment(
MktUserInviteStats,
{ activityId: activity.id, userId: invitation.inviterId },
'availableBalance',
amount,
);
// 如果是首单,增加下单人数
if (orderIndex === 1) {
await queryRunner.manager.increment(
MktUserInviteStats,
{ activityId: activity.id, userId: invitation.inviterId },
'totalOrders',
1,
);
}
await queryRunner.commitTransaction();
this.logger.log(
`订单 ${order.orderNo} 完成,邀请人 ${invitation.inviterId} 获得返现 ${amount}`,
);
} catch (err: any) {
await queryRunner.rollbackTransaction();
this.logger.error(`返现处理失败: ${err?.message}`);
} finally {
await queryRunner.release();
}
}
// ===== 管理端功能 =====
async getAdminStats(activityId?: number) {
const where: any = {};
if (activityId) where.activityId = activityId;
const [totalInvitations, totalCashbacks, totalWithdrawals, pendingWithdrawals] =
await Promise.all([
this.invitationRepo.count({ where }),
this.cashbackRepo.count({ where }),
this.withdrawalRepo.count({ where }),
this.withdrawalRepo.count({ where: { ...where, status: 'pending' } }),
]);
const cashbackSum = await this.cashbackRepo
.createQueryBuilder('cb')
.select('SUM(cb.amount)', 'total')
.getRawOne();
return {
totalInvitations,
totalCashbacks,
totalWithdrawals,
pendingWithdrawals,
totalCashbackAmount: cashbackSum?.total || 0,
};
}
async getAdminInvitations(dto: QueryInviteRecordsDto) {
const page = dto.page ?? 1;
const pageSize = dto.pageSize ?? 20;
const [list, total] = await this.invitationRepo.findAndCount({
relations: ['inviter', 'invitee'],
order: { createdAt: 'DESC' },
skip: (page - 1) * pageSize,
take: pageSize,
});
return { list, total };
}
async getAdminCashbacks(dto: QueryAdminCashbacksDto) {
const page = dto.page ?? 1;
const pageSize = dto.pageSize ?? 20;
const [list, total] = await this.cashbackRepo.findAndCount({
relations: ['inviter', 'invitee', 'order'],
order: { createdAt: 'DESC' },
skip: (page - 1) * pageSize,
take: pageSize,
});
return { list, total };
}
async getAdminWithdrawals(dto: QueryAdminInviteWithdrawalsDto) {
const page = dto.page ?? 1;
const pageSize = dto.pageSize ?? 20;
const where: any = {};
if (dto.status) where.status = dto.status;
const [list, total] = await this.withdrawalRepo.findAndCount({
where,
relations: ['user'],
order: { createdAt: 'DESC' },
skip: (page - 1) * pageSize,
take: pageSize,
});
return { list, total };
}
async approveWithdrawal(id: number, reviewerId: number) {
const withdrawal = await this.withdrawalRepo.findOne({ where: { id } });
if (!withdrawal) throw new NotFoundException('提现申请不存在');
if (withdrawal.status !== 'pending') {
throw new BadRequestException('当前状态不可审核');
}
withdrawal.status = 'approved';
withdrawal.reviewerId = reviewerId;
withdrawal.reviewedAt = new Date();
return this.withdrawalRepo.save(withdrawal);
}
async rejectWithdrawal(id: number, reviewerId: number, dto: RejectInviteWithdrawalDto) {
const withdrawal = await this.withdrawalRepo.findOne({ where: { id } });
if (!withdrawal) throw new NotFoundException('提现申请不存在');
if (withdrawal.status !== 'pending') {
throw new BadRequestException('当前状态不可审核');
}
const queryRunner = this.dataSource.createQueryRunner();
await queryRunner.connect();
await queryRunner.startTransaction();
try {
withdrawal.status = 'rejected';
withdrawal.reviewerId = reviewerId;
withdrawal.reviewedAt = new Date();
withdrawal.rejectReason = dto.reason;
await queryRunner.manager.save(withdrawal);
// 返还余额
await queryRunner.manager.increment(
MktUserInviteStats,
{ activityId: withdrawal.activityId, userId: withdrawal.userId },
'availableBalance',
withdrawal.amount,
);
await queryRunner.commitTransaction();
return withdrawal;
} catch (err) {
await queryRunner.rollbackTransaction();
throw err;
} finally {
await queryRunner.release();
}
}
async payWithdrawal(id: number) {
const withdrawal = await this.withdrawalRepo.findOne({ where: { id } });
if (!withdrawal) throw new NotFoundException('提现申请不存在');
if (withdrawal.status !== 'approved') {
throw new BadRequestException('当前状态不可打款');
}
withdrawal.status = 'paid';
withdrawal.paidAt = new Date();
// 更新已提现金额
await this.statsRepo.increment(
{ activityId: withdrawal.activityId, userId: withdrawal.userId },
'withdrawnAmount',
withdrawal.amount,
);
return this.withdrawalRepo.save(withdrawal);
}
}
@@ -0,0 +1,177 @@
import { IsOptional, IsString, IsBoolean, IsNumber, Min, Max, IsEnum, IsDateString, ValidateNested, IsObject } from 'class-validator';
import { Type } from 'class-transformer';
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
export class InviteCashbackConfig {
@ApiProperty({ description: '首单返现比例' })
@IsNumber()
firstOrderRate: number;
@ApiProperty({ description: '二单返现比例' })
@IsNumber()
secondOrderRate: number;
@ApiProperty({ description: '最低返现' })
@IsNumber()
minCashback: number;
@ApiProperty({ description: '最高返现' })
@IsNumber()
maxCashback: number;
@ApiProperty({ description: '提现门槛' })
@IsNumber()
withdrawThreshold: number;
}
// ===== 活动 DTO =====
export class CreateActivityDto {
@ApiProperty({ description: '活动名称' })
@IsString()
name: string;
@ApiProperty({ description: '活动类型', enum: ['invite_cashback'] })
@IsEnum(['invite_cashback'])
type: 'invite_cashback';
@ApiPropertyOptional({ description: '是否启用' })
@IsOptional()
@IsBoolean()
enabled?: boolean;
@ApiProperty({ description: '活动配置' })
@IsObject()
@ValidateNested()
@Type(() => InviteCashbackConfig)
config: InviteCashbackConfig;
@ApiPropertyOptional({ description: '活动开始时间' })
@IsOptional()
@IsDateString()
startTime?: string;
@ApiPropertyOptional({ description: '活动结束时间' })
@IsOptional()
@IsDateString()
endTime?: string;
}
export class UpdateActivityDto {
@ApiPropertyOptional({ description: '活动名称' })
@IsOptional()
@IsString()
name?: string;
@ApiPropertyOptional({ description: '活动配置' })
@IsOptional()
@IsObject()
@ValidateNested()
@Type(() => InviteCashbackConfig)
config?: InviteCashbackConfig;
@ApiPropertyOptional({ description: '活动开始时间' })
@IsOptional()
@IsDateString()
startTime?: string;
@ApiPropertyOptional({ description: '活动结束时间' })
@IsOptional()
@IsDateString()
endTime?: string;
}
export class QueryActivityDto {
@ApiPropertyOptional({ description: '页码' })
@IsOptional()
@Type(() => Number)
@IsNumber()
page?: number;
@ApiPropertyOptional({ description: '每页条数' })
@IsOptional()
@Type(() => Number)
@IsNumber()
pageSize?: number;
@ApiPropertyOptional({ description: '活动类型' })
@IsOptional()
@IsEnum(['invite_cashback'])
type?: 'invite_cashback';
@ApiPropertyOptional({ description: '是否启用' })
@IsOptional()
@IsBoolean()
enabled?: boolean;
}
// ===== 用户端邀请 DTO =====
export class BindInvitationDto {
@ApiProperty({ description: '邀请码' })
@IsString()
inviteCode: string;
}
export class CreateInviteWithdrawalDto {
@ApiProperty({ description: '提现金额' })
@Type(() => Number)
@IsNumber()
@Min(10)
amount: number;
}
export class QueryInviteRecordsDto {
@ApiPropertyOptional({ description: '页码' })
@IsOptional()
@Type(() => Number)
@IsNumber()
page?: number;
@ApiPropertyOptional({ description: '每页条数' })
@IsOptional()
@Type(() => Number)
@IsNumber()
pageSize?: number;
}
// ===== 管理端审核 DTO =====
export class RejectInviteWithdrawalDto {
@ApiProperty({ description: '拒绝原因' })
@IsString()
reason: string;
}
export class QueryAdminInviteWithdrawalsDto {
@ApiPropertyOptional({ description: '页码' })
@IsOptional()
@Type(() => Number)
@IsNumber()
page?: number;
@ApiPropertyOptional({ description: '每页条数' })
@IsOptional()
@Type(() => Number)
@IsNumber()
pageSize?: number;
@ApiPropertyOptional({ description: '状态筛选' })
@IsOptional()
@IsEnum(['pending', 'approved', 'rejected', 'paid'])
status?: 'pending' | 'approved' | 'rejected' | 'paid';
}
export class QueryAdminCashbacksDto {
@ApiPropertyOptional({ description: '页码' })
@IsOptional()
@Type(() => Number)
@IsNumber()
page?: number;
@ApiPropertyOptional({ description: '每页条数' })
@IsOptional()
@Type(() => Number)
@IsNumber()
pageSize?: number;
}
@@ -0,0 +1,166 @@
import { IsOptional, IsNumber, IsString, IsDateString, Min, IsIn, IsArray } from 'class-validator';
import { Type } from 'class-transformer';
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
// ==================== 商家端 DTO ====================
// 对账单列表查询
export class QuerySettlementDto {
@ApiPropertyOptional({ description: '页码', default: 1 })
@IsOptional()
@Type(() => Number)
@IsNumber()
@Min(1)
page?: number = 1;
@ApiPropertyOptional({ description: '每页数量', default: 10 })
@IsOptional()
@Type(() => Number)
@IsNumber()
@Min(1)
pageSize?: number = 10;
@ApiPropertyOptional({ description: '状态筛选', enum: ['pending', 'approved', 'rejected'] })
@IsOptional()
@IsIn(['pending', 'approved', 'rejected'])
status?: 'pending' | 'approved' | 'rejected';
@ApiPropertyOptional({ description: '周期开始日期' })
@IsOptional()
@IsDateString()
periodStart?: string;
@ApiPropertyOptional({ description: '周期结束日期' })
@IsOptional()
@IsDateString()
periodEnd?: string;
}
// 提现申请
export class CreateWithdrawalDto {
@ApiProperty({ description: '提现金额' })
@Type(() => Number)
@IsNumber()
@Min(0.01)
amount: number;
@ApiProperty({ description: '开户银行' })
@IsString()
bankName: string;
@ApiProperty({ description: '银行账号' })
@IsString()
bankAccount: string;
@ApiProperty({ description: '账户名' })
@IsString()
accountName: string;
}
// 更新银行卡信息
export class UpdateBankInfoDto {
@ApiProperty({ description: '开户银行' })
@IsString()
bankName: string;
@ApiProperty({ description: '银行账号' })
@IsString()
bankAccount: string;
@ApiProperty({ description: '账户名' })
@IsString()
accountName: string;
}
// 提现记录列表查询
export class QueryWithdrawalDto {
@ApiPropertyOptional({ description: '页码', default: 1 })
@IsOptional()
@Type(() => Number)
@IsNumber()
@Min(1)
page?: number = 1;
@ApiPropertyOptional({ description: '每页数量', default: 10 })
@IsOptional()
@Type(() => Number)
@IsNumber()
@Min(1)
pageSize?: number = 10;
@ApiPropertyOptional({ description: '状态筛选', enum: ['pending', 'approved', 'rejected', 'paid'] })
@IsOptional()
@IsIn(['pending', 'approved', 'rejected', 'paid'])
status?: 'pending' | 'approved' | 'rejected' | 'paid';
}
// ==================== 平台管理端 DTO ====================
// 管理端对账单列表查询
export class AdminQuerySettlementDto extends QuerySettlementDto {
@ApiPropertyOptional({ description: '商家ID' })
@IsOptional()
@Type(() => Number)
@IsNumber()
merchantId?: number;
}
// 审核通过对账单
export class ApproveSettlementDto {
@ApiPropertyOptional({ description: '备注' })
@IsOptional()
@IsString()
remark?: string;
}
// 拒绝对账单
export class RejectSettlementDto {
@ApiProperty({ description: '拒绝原因' })
@IsString()
rejectReason: string;
}
// 管理端提现列表查询
export class AdminQueryWithdrawalDto extends QueryWithdrawalDto {
@ApiPropertyOptional({ description: '商家ID' })
@IsOptional()
@Type(() => Number)
@IsNumber()
merchantId?: number;
}
// 审核通过提现
export class ApproveWithdrawalDto {
@ApiPropertyOptional({ description: '备注' })
@IsOptional()
@IsString()
remark?: string;
}
// 拒绝提现
export class RejectWithdrawalDto {
@ApiProperty({ description: '拒绝原因' })
@IsString()
rejectReason: string;
}
// 确认打款
export class PayWithdrawalDto {
@ApiPropertyOptional({ description: '打款备注' })
@IsOptional()
@IsString()
remark?: string;
}
// 平台收益统计查询
export class QueryEarningsDto {
@ApiPropertyOptional({ description: '开始日期' })
@IsOptional()
@IsDateString()
startDate?: string;
@ApiPropertyOptional({ description: '结束日期' })
@IsOptional()
@IsDateString()
endDate?: string;
}
@@ -0,0 +1,209 @@
import {
Controller,
Get,
Post,
Put,
Param,
Body,
Query,
UseGuards,
NotFoundException,
} from '@nestjs/common';
import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger';
import { FinanceService } from './finance.service';
import { SellerJwtAuthGuard } from '@/common/guards/seller-jwt-auth.guard';
import { JwtAuthGuard, RolesGuard } from '@/common';
import { Roles } from '@/common/decorators/roles.decorator';
import { CurrentSeller } from '@/common/decorators/current-seller.decorator';
import { CurrentUser } from '@/common/decorators/current-user.decorator';
import { MerchantService } from '../merchant/merchant.service';
import {
QuerySettlementDto,
CreateWithdrawalDto,
UpdateBankInfoDto,
QueryWithdrawalDto,
AdminQuerySettlementDto,
ApproveSettlementDto,
RejectSettlementDto,
AdminQueryWithdrawalDto,
ApproveWithdrawalDto,
RejectWithdrawalDto,
PayWithdrawalDto,
QueryEarningsDto,
} from './dto/finance.dto';
// ==================== 商家端 ====================
@ApiTags('财务管理(商家)')
@Controller('seller/finance')
@UseGuards(SellerJwtAuthGuard)
@ApiBearerAuth()
export class MerchantFinanceController {
constructor(
private readonly financeService: FinanceService,
private readonly merchantService: MerchantService,
) {}
private async getMerchantId(sellerId: number): Promise<number> {
const merchant = await this.merchantService.findBySellerId(sellerId);
if (!merchant) throw new NotFoundException('店铺不存在');
return merchant.id;
}
// 钱包信息
@Get('wallet')
@ApiOperation({ summary: '获取钱包信息' })
async getWallet(@CurrentSeller('sub') sellerId: number) {
const merchantId = await this.getMerchantId(sellerId);
return this.financeService.getWallet(merchantId);
}
// 更新银行卡
@Put('bank')
@ApiOperation({ summary: '更新银行卡信息' })
async updateBankInfo(
@CurrentSeller('sub') sellerId: number,
@Body() dto: UpdateBankInfoDto,
) {
const merchantId = await this.getMerchantId(sellerId);
return this.financeService.updateBankInfo(merchantId, dto);
}
// 申请提现
@Post('withdraw')
@ApiOperation({ summary: '申请提现' })
async createWithdrawal(
@CurrentSeller('sub') sellerId: number,
@Body() dto: CreateWithdrawalDto,
) {
const merchantId = await this.getMerchantId(sellerId);
return this.financeService.createWithdrawal(merchantId, dto);
}
// 对账单列表
@Get('settlements')
@ApiOperation({ summary: '对账单列表' })
async getSettlements(
@CurrentSeller('sub') sellerId: number,
@Query() dto: QuerySettlementDto,
) {
const merchantId = await this.getMerchantId(sellerId);
return this.financeService.getSettlements(merchantId, dto);
}
// 对账单详情
@Get('settlements/:id')
@ApiOperation({ summary: '对账单详情' })
async getSettlementDetail(
@CurrentSeller('sub') sellerId: number,
@Param('id') id: number,
) {
const merchantId = await this.getMerchantId(sellerId);
return this.financeService.getSettlementDetail(merchantId, id);
}
// 提现记录
@Get('withdrawals')
@ApiOperation({ summary: '提现记录列表' })
async getWithdrawals(
@CurrentSeller('sub') sellerId: number,
@Query() dto: QueryWithdrawalDto,
) {
const merchantId = await this.getMerchantId(sellerId);
return this.financeService.getWithdrawals(merchantId, dto);
}
}
// ==================== 平台管理端 ====================
@ApiTags('财务管理(管理员)')
@Controller('admin/finance')
@UseGuards(JwtAuthGuard, RolesGuard)
@Roles('admin')
@ApiBearerAuth()
export class AdminFinanceController {
constructor(private readonly financeService: FinanceService) {}
// 对账单列表
@Get('settlements')
@ApiOperation({ summary: '对账单列表' })
async getSettlements(@Query() dto: AdminQuerySettlementDto) {
return this.financeService.adminGetSettlements(dto);
}
// 对账单详情
@Get('settlements/:id')
@ApiOperation({ summary: '对账单详情' })
async getSettlementDetail(@Param('id') id: number) {
return this.financeService.adminGetSettlementDetail(id);
}
// 审核通过对账单
@Put('settlements/:id/approve')
@ApiOperation({ summary: '审核通过对账单' })
async approveSettlement(
@CurrentUser('sub') reviewerId: number,
@Param('id') id: number,
@Body() _dto: ApproveSettlementDto,
) {
return this.financeService.approveSettlement(id, reviewerId);
}
// 拒绝对账单
@Put('settlements/:id/reject')
@ApiOperation({ summary: '拒绝对账单' })
async rejectSettlement(
@CurrentUser('sub') reviewerId: number,
@Param('id') id: number,
@Body() dto: RejectSettlementDto,
) {
return this.financeService.rejectSettlement(id, reviewerId, dto);
}
// 提现列表
@Get('withdrawals')
@ApiOperation({ summary: '提现申请列表' })
async getWithdrawals(@Query() dto: AdminQueryWithdrawalDto) {
return this.financeService.adminGetWithdrawals(dto);
}
// 审核通过提现
@Put('withdrawals/:id/approve')
@ApiOperation({ summary: '审核通过提现' })
async approveWithdrawal(
@CurrentUser('sub') reviewerId: number,
@Param('id') id: number,
@Body() _dto: ApproveWithdrawalDto,
) {
return this.financeService.approveWithdrawal(id, reviewerId);
}
// 拒绝提现
@Put('withdrawals/:id/reject')
@ApiOperation({ summary: '拒绝提现' })
async rejectWithdrawal(
@CurrentUser('sub') reviewerId: number,
@Param('id') id: number,
@Body() dto: RejectWithdrawalDto,
) {
return this.financeService.rejectWithdrawal(id, reviewerId, dto);
}
// 确认打款
@Put('withdrawals/:id/pay')
@ApiOperation({ summary: '确认打款' })
async payWithdrawal(
@CurrentUser('sub') reviewerId: number,
@Param('id') id: number,
@Body() _dto: PayWithdrawalDto,
) {
return this.financeService.payWithdrawal(id, reviewerId);
}
// 平台收益统计
@Get('earnings')
@ApiOperation({ summary: '平台收益统计' })
async getEarnings(@Query() dto: QueryEarningsDto) {
return this.financeService.getEarnings(dto);
}
}
@@ -0,0 +1,21 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { Settlement } from '@/entities/settlement.entity';
import { SettlementItem } from '@/entities/settlement-item.entity';
import { Withdrawal } from '@/entities/withdrawal.entity';
import { Merchant } from '@/entities/merchant.entity';
import { Order } from '@/entities/order.entity';
import { FinanceService } from './finance.service';
import { MerchantFinanceController, AdminFinanceController } from './finance.controller';
import { MerchantModule } from '../merchant/merchant.module';
@Module({
imports: [
TypeOrmModule.forFeature([Settlement, SettlementItem, Withdrawal, Merchant, Order]),
MerchantModule,
],
controllers: [MerchantFinanceController, AdminFinanceController],
providers: [FinanceService],
exports: [FinanceService],
})
export class FinanceModule {}
@@ -0,0 +1,452 @@
import { Injectable, NotFoundException, BadRequestException } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository, DataSource, Between, FindOptionsWhere } from 'typeorm';
import { Settlement } from '@/entities/settlement.entity';
import { SettlementItem } from '@/entities/settlement-item.entity';
import { Withdrawal } from '@/entities/withdrawal.entity';
import { Merchant } from '@/entities/merchant.entity';
import { Order } from '@/entities/order.entity';
import {
QuerySettlementDto,
CreateWithdrawalDto,
UpdateBankInfoDto,
QueryWithdrawalDto,
AdminQuerySettlementDto,
RejectSettlementDto,
AdminQueryWithdrawalDto,
RejectWithdrawalDto,
QueryEarningsDto,
} from './dto/finance.dto';
@Injectable()
export class FinanceService {
constructor(
@InjectRepository(Settlement)
private settlementRepo: Repository<Settlement>,
@InjectRepository(SettlementItem)
private settlementItemRepo: Repository<SettlementItem>,
@InjectRepository(Withdrawal)
private withdrawalRepo: Repository<Withdrawal>,
@InjectRepository(Merchant)
private merchantRepo: Repository<Merchant>,
@InjectRepository(Order)
private orderRepo: Repository<Order>,
private dataSource: DataSource,
) {}
// ==================== 商家端 ====================
// 对账单列表
async getSettlements(merchantId: number, dto: QuerySettlementDto) {
const where: FindOptionsWhere<Settlement> = { merchantId };
if (dto.status) where.status = dto.status;
if (dto.periodStart && dto.periodEnd) {
where.periodStart = Between(dto.periodStart, dto.periodEnd);
}
const [list, total] = await this.settlementRepo.findAndCount({
where,
order: { createdAt: 'DESC' },
skip: ((dto.page ?? 1) - 1) * (dto.pageSize ?? 10),
take: dto.pageSize ?? 10,
});
return { list, total, page: dto.page ?? 1, pageSize: dto.pageSize ?? 10 };
}
// 对账单详情(含明细)
async getSettlementDetail(merchantId: number, id: number) {
const settlement = await this.settlementRepo.findOne({
where: { id, merchantId },
relations: ['items'],
});
if (!settlement) throw new NotFoundException('对账单不存在');
return settlement;
}
// 钱包信息
async getWallet(merchantId: number) {
const merchant = await this.merchantRepo.findOne({
where: { id: merchantId },
select: ['id', 'walletBalance', 'bankName', 'bankAccount', 'accountName'],
});
if (!merchant) throw new NotFoundException('商家不存在');
const pendingAmount = await this.withdrawalRepo
.createQueryBuilder('w')
.where('w.merchant_id = :merchantId AND w.status IN (:...statuses)', {
merchantId,
statuses: ['pending', 'approved'],
})
.select('COALESCE(SUM(w.amount), 0)', 'total')
.getRawOne();
return {
walletBalance: merchant.walletBalance,
bankName: merchant.bankName,
bankAccount: merchant.bankAccount,
accountName: merchant.accountName,
pendingWithdrawAmount: Number(pendingAmount?.total || 0),
};
}
// 更新银行卡信息
async updateBankInfo(merchantId: number, dto: UpdateBankInfoDto) {
await this.merchantRepo.update({ id: merchantId }, {
bankName: dto.bankName,
bankAccount: dto.bankAccount,
accountName: dto.accountName,
});
return { success: true };
}
// 申请提现
async createWithdrawal(merchantId: number, dto: CreateWithdrawalDto) {
const merchant = await this.merchantRepo.findOne({ where: { id: merchantId } });
if (!merchant) throw new NotFoundException('商家不存在');
if (Number(merchant.walletBalance) < dto.amount) {
throw new BadRequestException('余额不足');
}
if (dto.amount < 100) {
throw new BadRequestException('最低提现金额为100元');
}
// 查找已审核通过的对账单
const approvedSettlements = await this.settlementRepo.find({
where: { merchantId, status: 'approved' },
order: { createdAt: 'ASC' },
});
// 计算手续费率
const feeRate = 0.006;
const fee = Math.round(dto.amount * feeRate * 100) / 100;
const commissionRate = Number(merchant.walletBalance) > 0 ? 0.10 : 0;
const commissionAmount = Math.round(dto.amount * commissionRate * 100) / 100;
const actualAmount = Math.round((dto.amount - fee - commissionAmount) * 100) / 100;
const queryRunner = this.dataSource.createQueryRunner();
await queryRunner.connect();
await queryRunner.startTransaction();
try {
// 扣减商家余额
await queryRunner.manager.decrement(Merchant, { id: merchantId }, 'walletBalance', dto.amount);
const withdrawal = queryRunner.manager.create(Withdrawal, {
merchantId,
settlementIds: approvedSettlements.map(s => s.id),
amount: dto.amount,
fee,
commissionAmount,
actualAmount,
bankName: dto.bankName,
bankAccount: dto.bankAccount,
accountName: dto.accountName,
status: 'pending',
});
await queryRunner.manager.save(withdrawal);
await queryRunner.commitTransaction();
return withdrawal;
} catch (err) {
await queryRunner.rollbackTransaction();
throw err;
} finally {
await queryRunner.release();
}
}
// 提现记录列表
async getWithdrawals(merchantId: number, dto: QueryWithdrawalDto) {
const where: FindOptionsWhere<Withdrawal> = { merchantId };
if (dto.status) where.status = dto.status;
const [list, total] = await this.withdrawalRepo.findAndCount({
where,
order: { createdAt: 'DESC' },
skip: ((dto.page ?? 1) - 1) * (dto.pageSize ?? 10),
take: dto.pageSize ?? 10,
});
return { list, total, page: dto.page ?? 1, pageSize: dto.pageSize ?? 10 };
}
// ==================== 平台管理端 ====================
// 管理端 - 对账单列表
async adminGetSettlements(dto: AdminQuerySettlementDto) {
const where: FindOptionsWhere<Settlement> = {};
if (dto.status) where.status = dto.status;
if (dto.merchantId) where.merchantId = dto.merchantId;
if (dto.periodStart && dto.periodEnd) {
where.periodStart = Between(dto.periodStart, dto.periodEnd);
}
const [list, total] = await this.settlementRepo.findAndCount({
where,
relations: ['merchant'],
order: { createdAt: 'DESC' },
skip: ((dto.page ?? 1) - 1) * (dto.pageSize ?? 10),
take: dto.pageSize ?? 10,
});
return { list, total, page: dto.page ?? 1, pageSize: dto.pageSize ?? 10 };
}
// 管理端 - 对账单详情
async adminGetSettlementDetail(id: number) {
const settlement = await this.settlementRepo.findOne({
where: { id },
relations: ['merchant', 'items'],
});
if (!settlement) throw new NotFoundException('对账单不存在');
return settlement;
}
// 审核通过对账单
async approveSettlement(id: number, reviewerId: number) {
const settlement = await this.settlementRepo.findOne({ where: { id } });
if (!settlement) throw new NotFoundException('对账单不存在');
if (settlement.status !== 'pending') throw new BadRequestException('只能审核待审核的对账单');
const queryRunner = this.dataSource.createQueryRunner();
await queryRunner.connect();
await queryRunner.startTransaction();
try {
await queryRunner.manager.update(Settlement, { id }, {
status: 'approved',
reviewerId,
reviewedAt: new Date(),
});
// 对账单金额进入商家待提现余额
await queryRunner.manager.increment(
Merchant,
{ id: settlement.merchantId },
'walletBalance',
Number(settlement.settlementAmount),
);
await queryRunner.commitTransaction();
return { success: true };
} catch (err) {
await queryRunner.rollbackTransaction();
throw err;
} finally {
await queryRunner.release();
}
}
// 拒绝对账单
async rejectSettlement(id: number, reviewerId: number, dto: RejectSettlementDto) {
const settlement = await this.settlementRepo.findOne({ where: { id } });
if (!settlement) throw new NotFoundException('对账单不存在');
if (settlement.status !== 'pending') throw new BadRequestException('只能审核待审核的对账单');
await this.settlementRepo.update({ id }, {
status: 'rejected',
reviewerId,
reviewedAt: new Date(),
rejectReason: dto.rejectReason,
});
return { success: true };
}
// 管理端 - 提现列表
async adminGetWithdrawals(dto: AdminQueryWithdrawalDto) {
const where: FindOptionsWhere<Withdrawal> = {};
if (dto.status) where.status = dto.status;
if (dto.merchantId) where.merchantId = dto.merchantId;
const [list, total] = await this.withdrawalRepo.findAndCount({
where,
relations: ['merchant'],
order: { createdAt: 'DESC' },
skip: ((dto.page ?? 1) - 1) * (dto.pageSize ?? 10),
take: dto.pageSize ?? 10,
});
return { list, total, page: dto.page ?? 1, pageSize: dto.pageSize ?? 10 };
}
// 审核通过提现
async approveWithdrawal(id: number, reviewerId: number) {
const withdrawal = await this.withdrawalRepo.findOne({ where: { id } });
if (!withdrawal) throw new NotFoundException('提现记录不存在');
if (withdrawal.status !== 'pending') throw new BadRequestException('只能审核待审核的提现');
await this.withdrawalRepo.update({ id }, {
status: 'approved',
reviewerId,
reviewedAt: new Date(),
});
return { success: true };
}
// 拒绝提现(退还余额)
async rejectWithdrawal(id: number, reviewerId: number, dto: RejectWithdrawalDto) {
const withdrawal = await this.withdrawalRepo.findOne({ where: { id } });
if (!withdrawal) throw new NotFoundException('提现记录不存在');
if (withdrawal.status !== 'pending') throw new BadRequestException('只能审核待审核的提现');
const queryRunner = this.dataSource.createQueryRunner();
await queryRunner.connect();
await queryRunner.startTransaction();
try {
await queryRunner.manager.update(Withdrawal, { id }, {
status: 'rejected',
reviewerId,
reviewedAt: new Date(),
rejectReason: dto.rejectReason,
});
// 退还商家余额
await queryRunner.manager.increment(
Merchant,
{ id: withdrawal.merchantId },
'walletBalance',
Number(withdrawal.amount),
);
await queryRunner.commitTransaction();
return { success: true };
} catch (err) {
await queryRunner.rollbackTransaction();
throw err;
} finally {
await queryRunner.release();
}
}
// 确认打款
async payWithdrawal(id: number, reviewerId: number) {
const withdrawal = await this.withdrawalRepo.findOne({ where: { id } });
if (!withdrawal) throw new NotFoundException('提现记录不存在');
if (withdrawal.status !== 'approved') throw new BadRequestException('只能对已审核通过的提现确认打款');
await this.withdrawalRepo.update({ id }, {
status: 'paid',
paidAt: new Date(),
});
return { success: true };
}
// 平台收益统计
async getEarnings(dto: QueryEarningsDto) {
const qb = this.settlementRepo.createQueryBuilder('s');
if (dto.startDate && dto.endDate) {
qb.where('s.period_start BETWEEN :startDate AND :endDate', {
startDate: dto.startDate,
endDate: dto.endDate,
});
}
const settlementStats = await qb
.select('COUNT(*)', 'totalSettlements')
.addSelect('COALESCE(SUM(s.order_amount), 0)', 'totalOrderAmount')
.addSelect('COALESCE(SUM(s.commission_amount), 0)', 'totalCommission')
.addSelect('COALESCE(SUM(s.settlement_amount), 0)', 'totalSettlementAmount')
.getRawOne();
// 提现统计
const wQb = this.withdrawalRepo.createQueryBuilder('w');
const withdrawalStats = await wQb
.select('COUNT(*)', 'totalWithdrawals')
.addSelect('COALESCE(SUM(CASE WHEN w.status = \'paid\' THEN w.amount ELSE 0 END), 0)', 'paidAmount')
.addSelect('COALESCE(SUM(w.fee), 0)', 'totalFee')
.addSelect('COALESCE(SUM(w.commission_amount), 0)', 'totalWithdrawCommission')
.getRawOne();
return {
orderAmount: Number(settlementStats?.totalOrderAmount || 0),
commission: Number(settlementStats?.totalCommission || 0),
settlementAmount: Number(settlementStats?.totalSettlementAmount || 0),
totalSettlements: Number(settlementStats?.totalSettlements || 0),
paidAmount: Number(withdrawalStats?.paidAmount || 0),
totalFee: Number(withdrawalStats?.totalFee || 0),
totalWithdrawCommission: Number(withdrawalStats?.totalWithdrawCommission || 0),
totalWithdrawals: Number(withdrawalStats?.totalWithdrawals || 0),
};
}
// ==================== 定时任务调用 ====================
// 生成周期对账单
async generateSettlements(periodStart: string, periodEnd: string) {
// 查找所有已审核通过的商家
const merchants = await this.merchantRepo.find({ where: { status: 'approved' } });
const results: { merchantId: number; settlementId: number; orderCount: number }[] = [];
for (const merchant of merchants) {
// 检查是否已生成过该周期的对账单
const existing = await this.settlementRepo.findOne({
where: { merchantId: merchant.id, periodStart, periodEnd },
});
if (existing) continue;
// 查询该周期内已完成的订单
const orders = await this.orderRepo
.createQueryBuilder('o')
.where('o.merchant_id = :merchantId', { merchantId: merchant.id })
.andWhere('o.status = :status', { status: 'completed' })
.andWhere('o.check_in_date >= :periodStart', { periodStart })
.andWhere('o.check_in_date <= :periodEnd', { periodEnd })
.getMany();
if (orders.length === 0) continue;
const orderAmount = orders.reduce((sum, o) => sum + Number(o.payAmount), 0);
const commissionRate = 0.10;
const commissionAmount = Math.round(orderAmount * commissionRate * 100) / 100;
const settlementAmount = Math.round((orderAmount - commissionAmount) * 100) / 100;
const settlementNo = `ST${Date.now()}${merchant.id}`;
const queryRunner = this.dataSource.createQueryRunner();
await queryRunner.connect();
await queryRunner.startTransaction();
try {
const settlement = queryRunner.manager.create(Settlement, {
merchantId: merchant.id,
settlementNo,
periodStart,
periodEnd,
orderCount: orders.length,
orderAmount,
commissionRate,
commissionAmount,
settlementAmount,
status: 'pending',
});
await queryRunner.manager.save(settlement);
// 保存对账单明细
const items = orders.map(o => queryRunner.manager.create(SettlementItem, {
settlementId: settlement.id,
orderId: o.id,
orderNo: o.orderNo,
orderAmount: Number(o.payAmount),
}));
await queryRunner.manager.save(items);
await queryRunner.commitTransaction();
results.push({ merchantId: merchant.id, settlementId: settlement.id, orderCount: orders.length });
} catch (err) {
await queryRunner.rollbackTransaction();
console.error(`生成对账单失败 merchantId=${merchant.id}:`, err.message);
} finally {
await queryRunner.release();
}
}
return results;
}
}
@@ -1,4 +1,4 @@
import { Module } from '@nestjs/common';
import { Module, forwardRef } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { Order } from '@/entities/order.entity';
import { Room } from '@/entities/room.entity';
@@ -6,11 +6,13 @@ import { RoomCalendar } from '@/entities/room-calendar.entity';
import { OrderService } from './order.service';
import { UserOrderController, MerchantOrderController, AdminOrderController } from './order.controller';
import { MerchantModule } from '../merchant/merchant.module';
import { ActivityModule } from '../activity/activity.module';
@Module({
imports: [
TypeOrmModule.forFeature([Order, Room, RoomCalendar]),
MerchantModule,
forwardRef(() => ActivityModule),
],
controllers: [UserOrderController, MerchantOrderController, AdminOrderController],
providers: [OrderService],
+56 -10
View File
@@ -5,6 +5,7 @@ import { Order } from '@/entities/order.entity';
import { Room } from '@/entities/room.entity';
import { RoomCalendar } from '@/entities/room-calendar.entity';
import { CreateOrderDto, QueryOrderDto, ConfirmOrderDto } from './dto/order.dto';
import { ActivityService } from '@/modules/activity/activity.service';
@Injectable()
export class OrderService {
@@ -15,6 +16,7 @@ export class OrderService {
private roomRepo: Repository<Room>,
@InjectRepository(RoomCalendar)
private calendarRepo: Repository<RoomCalendar>,
private readonly activityService: ActivityService,
) {}
async create(userId: number, dto: CreateOrderDto) {
@@ -30,9 +32,8 @@ export class OrderService {
const roomCount = dto.roomCount || 1;
const roomAmount = room.price * nights * roomCount;
const serviceFee = Math.round(roomAmount * 0.05 * 100) / 100;
const couponDiscount = 0; // TODO: 计算优惠券抵扣
const totalAmount = roomAmount + serviceFee;
const totalAmount = roomAmount;
const payAmount = totalAmount - couponDiscount;
const now = new Date();
@@ -58,7 +59,6 @@ export class OrderService {
guestCount: dto.guestCount || 1,
roomPrice: room.price,
roomAmount,
serviceFee,
couponDiscount,
totalAmount,
payAmount,
@@ -121,16 +121,39 @@ export class OrderService {
async cancel(userId: number, id: number, reason: string) {
const order = await this.findById(id);
if (order.userId !== userId) throw new ForbiddenException('无权操作此订单');
if (!['pending_pay', 'pending_confirm'].includes(order.status)) {
if (!['pending_pay', 'pending_confirm', 'pending_checkin'].includes(order.status)) {
throw new BadRequestException('当前订单状态不可取消');
}
// 已支付的订单(pending_confirm、pending_checkin)取消时需退款并恢复库存
const needRefund = order.status !== 'pending_pay';
if (needRefund) {
// 恢复房态库存
const checkIn = new Date(order.checkInDate);
const checkOut = new Date(order.checkOutDate);
for (let d = new Date(checkIn); d < checkOut; d.setDate(d.getDate() + 1)) {
const dateStr = d.toISOString().split('T')[0];
const calendar = await this.calendarRepo.findOne({
where: { roomId: order.roomId, date: dateStr },
});
if (calendar) {
await this.calendarRepo.update(calendar.id, {
sold: Math.max(0, calendar.sold - order.roomCount),
});
}
}
}
await this.orderRepo.update(id, {
status: 'cancelled',
cancelReason: reason,
cancelledAt: new Date(),
...(needRefund ? {
refundAmount: order.payAmount,
refundAt: new Date(),
} : {}),
});
return { message: '订单已取消' };
return { message: needRefund ? '订单已取消,退款将原路返回' : '订单已取消' };
}
async confirm(merchantId: number, id: number) {
@@ -186,10 +209,14 @@ export class OrderService {
status: 'completed',
checkoutAt: new Date(),
});
// 触发邀请返现
this.activityService.handleOrderCompleted(id).catch(() => {});
return { message: '订单已完成' };
}
// 用户申请退款(待确认、待入住状态)
// 用户申请退款(待确认、待入住状态)- 直接退款,无需审核
async refund(userId: number, id: number, reason: string) {
const order = await this.findById(id);
if (order.userId !== userId) throw new ForbiddenException('无权操作此订单');
@@ -197,14 +224,33 @@ export class OrderService {
throw new BadRequestException('当前订单状态不可申请退款');
}
// 恢复房态库存
const checkIn = new Date(order.checkInDate);
const checkOut = new Date(order.checkOutDate);
for (let d = new Date(checkIn); d < checkOut; d.setDate(d.getDate() + 1)) {
const dateStr = d.toISOString().split('T')[0];
const calendar = await this.calendarRepo.findOne({
where: { roomId: order.roomId, date: dateStr },
});
if (calendar) {
await this.calendarRepo.update(calendar.id, {
sold: Math.max(0, calendar.sold - order.roomCount),
});
}
}
// 直接退款,状态改为 refunded
await this.orderRepo.update(id, {
status: 'refunding',
status: 'refunded',
cancelReason: reason,
refundAmount: order.payAmount,
refundAt: new Date(),
cancelledAt: new Date(),
});
return { message: '退款申请已提交' };
return { message: '退款成功' };
}
// 商家同意退款
// 商家同意退款(已废弃,退款不再需要审核)
async approveRefund(merchantId: number, id: number) {
const order = await this.findById(id);
if (order.merchantId !== merchantId) throw new ForbiddenException('无权操作此订单');
@@ -236,7 +282,7 @@ export class OrderService {
return { message: '退款成功' };
}
// 商家拒绝退款
// 商家拒绝退款(已废弃,退款不再需要审核)
async rejectRefund(merchantId: number, id: number, reason: string) {
const order = await this.findById(id);
if (order.merchantId !== merchantId) throw new ForbiddenException('无权操作此订单');
@@ -0,0 +1,41 @@
import { IsNotEmpty, IsOptional, IsNumber, IsString, IsBoolean, Min, Max, IsInt } from 'class-validator';
import { Type } from 'class-transformer';
export class CreateReviewDto {
@IsNumber()
@Type(() => Number)
rating: number;
@IsOptional()
@IsString()
content?: string;
@IsOptional()
images?: string[];
@IsOptional()
@IsBoolean()
isAnonymous?: boolean;
}
export class QueryReviewDto {
@IsOptional()
@Type(() => Number)
page?: number = 1;
@IsOptional()
@Type(() => Number)
pageSize?: number = 10;
@IsOptional()
@Type(() => Number)
merchantId?: number;
@IsOptional()
@Type(() => Number)
roomId?: number;
@IsOptional()
@IsString()
auditStatus?: string;
}
@@ -0,0 +1,71 @@
import {
Controller,
Get,
Post,
Put,
Body,
Param,
Query,
UseGuards,
Request,
} from '@nestjs/common';
import { ReviewService } from './review.service';
import { CreateReviewDto, QueryReviewDto } from './dto/review.dto';
import { JwtAuthGuard, RolesGuard } from '@/common';
import { Roles } from '@/common/decorators/roles.decorator';
// 用户端评价接口
@Controller('reviews')
export class ReviewPublicController {
constructor(private readonly reviewService: ReviewService) {}
// 提交评价(需登录)
@Post('order/:orderId')
@UseGuards(JwtAuthGuard)
async create(
@Request() req,
@Param('orderId') orderId: number,
@Body() dto: CreateReviewDto,
) {
return this.reviewService.create(req.user.userId, Number(orderId), dto);
}
// 查询评价列表(公开)
@Get()
async list(@Query() query: QueryReviewDto) {
return this.reviewService.findPublic(query);
}
// 检查订单是否已评价
@Get('check/:orderId')
async checkOrder(@Param('orderId') orderId: number) {
const review = await this.reviewService.findByOrder(Number(orderId));
return { reviewed: !!review, review };
}
}
// 管理员评价审核接口
@Controller('admin/reviews')
@UseGuards(JwtAuthGuard, RolesGuard)
@Roles('admin')
export class AdminReviewController {
constructor(private readonly reviewService: ReviewService) {}
// 评价审核列表
@Get()
async list(@Query() query: QueryReviewDto) {
return this.reviewService.findAllForAdmin(query);
}
// 审核通过
@Put(':id/approve')
async approve(@Param('id') id: number) {
return this.reviewService.approve(Number(id));
}
// 审核拒绝
@Put(':id/reject')
async reject(@Param('id') id: number, @Body('reason') reason: string) {
return this.reviewService.reject(Number(id), reason);
}
}
@@ -0,0 +1,16 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { Review } from '@/entities/review.entity';
import { Order } from '@/entities/order.entity';
import { Merchant } from '@/entities/merchant.entity';
import { Room } from '@/entities/room.entity';
import { ReviewService } from './review.service';
import { ReviewPublicController, AdminReviewController } from './review.controller';
@Module({
imports: [TypeOrmModule.forFeature([Review, Order, Merchant, Room])],
controllers: [ReviewPublicController, AdminReviewController],
providers: [ReviewService],
exports: [ReviewService],
})
export class ReviewModule {}
@@ -0,0 +1,164 @@
import {
Injectable,
NotFoundException,
BadRequestException,
ForbiddenException,
} from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { Review } from '@/entities/review.entity';
import { Order } from '@/entities/order.entity';
import { Merchant } from '@/entities/merchant.entity';
import { Room } from '@/entities/room.entity';
import { CreateReviewDto, QueryReviewDto } from './dto/review.dto';
@Injectable()
export class ReviewService {
constructor(
@InjectRepository(Review)
private reviewRepo: Repository<Review>,
@InjectRepository(Order)
private orderRepo: Repository<Order>,
@InjectRepository(Merchant)
private merchantRepo: Repository<Merchant>,
@InjectRepository(Room)
private roomRepo: Repository<Room>,
) {}
// 用户提交评价
async create(userId: number, orderId: number, dto: CreateReviewDto) {
const order = await this.orderRepo.findOne({ where: { id: orderId } });
if (!order) throw new NotFoundException('订单不存在');
if (order.userId !== userId) throw new ForbiddenException('无权操作此订单');
if (order.status !== 'completed') throw new BadRequestException('只有已完成的订单才能评价');
// 检查是否已评价
const existing = await this.reviewRepo.findOne({ where: { orderId } });
if (existing) throw new BadRequestException('该订单已评价');
if (dto.rating < 1 || dto.rating > 5) throw new BadRequestException('评分范围为1-5');
const review = this.reviewRepo.create({
orderId,
userId,
merchantId: order.merchantId,
roomId: order.roomId,
rating: dto.rating,
content: dto.content,
images: dto.images,
isAnonymous: dto.isAnonymous || false,
auditStatus: 'pending',
});
await this.reviewRepo.save(review);
return { message: '评价已提交,等待审核' };
}
// 查询评价列表(公开,只显示审核通过的)
async findPublic(query: QueryReviewDto) {
const { page = 1, pageSize = 10, merchantId, roomId } = query;
const qb = this.reviewRepo
.createQueryBuilder('r')
.leftJoinAndSelect('r.order', 'o')
.where('r.auditStatus = :auditStatus', { auditStatus: 'approved' });
if (merchantId) {
qb.andWhere('r.merchantId = :merchantId', { merchantId: Number(merchantId) });
}
if (roomId) {
qb.andWhere('r.roomId = :roomId', { roomId: Number(roomId) });
}
qb.orderBy('r.createdAt', 'DESC');
const safePage = Number(page) || 1;
const safePageSize = Number(pageSize) || 10;
qb.skip((safePage - 1) * safePageSize).take(safePageSize);
const [list, total] = await qb.getManyAndCount();
return { list, total, page: safePage, pageSize: safePageSize, totalPages: Math.ceil(total / safePageSize) };
}
// 检查订单是否已评价
async findByOrder(orderId: number) {
return this.reviewRepo.findOne({ where: { orderId } });
}
// 管理员审核评价列表
async findAllForAdmin(query: QueryReviewDto) {
const { page = 1, pageSize = 10, auditStatus } = query;
const qb = this.reviewRepo
.createQueryBuilder('r')
.leftJoinAndSelect('r.order', 'o');
if (auditStatus) {
qb.andWhere('r.auditStatus = :auditStatus', { auditStatus });
}
qb.orderBy('r.createdAt', 'DESC');
const safePage = Number(page) || 1;
const safePageSize = Number(pageSize) || 10;
qb.skip((safePage - 1) * safePageSize).take(safePageSize);
const [list, total] = await qb.getManyAndCount();
return { list, total, page: safePage, pageSize: safePageSize, totalPages: Math.ceil(total / safePageSize) };
}
// 审核通过
async approve(id: number) {
const review = await this.reviewRepo.findOne({ where: { id } });
if (!review) throw new NotFoundException('评价不存在');
await this.reviewRepo.update(id, { auditStatus: 'approved', auditRejectReason: '' });
// 更新商家和房源的评分
await this.updateMerchantRating(review.merchantId);
await this.updateRoomRating(review.roomId);
return { message: '评价已通过审核' };
}
// 审核拒绝
async reject(id: number, reason: string) {
const review = await this.reviewRepo.findOne({ where: { id } });
if (!review) throw new NotFoundException('评价不存在');
await this.reviewRepo.update(id, { auditStatus: 'rejected', auditRejectReason: reason });
return { message: '评价已拒绝' };
}
// 更新商家平均评分
private async updateMerchantRating(merchantId: number) {
const result = await this.reviewRepo
.createQueryBuilder('r')
.select('AVG(r.rating)', 'avgRating')
.addSelect('COUNT(r.id)', 'count')
.where('r.merchantId = :merchantId', { merchantId })
.andWhere('r.auditStatus = :auditStatus', { auditStatus: 'approved' })
.getRawOne();
if (result) {
await this.merchantRepo.update(merchantId, {
rating: parseFloat(parseFloat(result.avgRating || 0).toFixed(1)),
reviewCount: parseInt(result.count || 0),
});
}
}
// 更新房源平均评分
private async updateRoomRating(roomId: number) {
const result = await this.reviewRepo
.createQueryBuilder('r')
.select('AVG(r.rating)', 'avgRating')
.addSelect('COUNT(r.id)', 'count')
.where('r.roomId = :roomId', { roomId })
.andWhere('r.auditStatus = :auditStatus', { auditStatus: 'approved' })
.getRawOne();
if (result) {
await this.roomRepo.update(roomId, {
rating: parseFloat(parseFloat(result.avgRating || 0).toFixed(1)),
reviewCount: parseInt(result.count || 0),
});
}
}
}
@@ -0,0 +1,37 @@
import { Injectable, Logger } from '@nestjs/common';
import { Cron } from '@nestjs/schedule';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { Order } from '@/entities/order.entity';
@Injectable()
export class OrderSchedule {
private readonly logger = new Logger(OrderSchedule.name);
constructor(
@InjectRepository(Order)
private orderRepo: Repository<Order>,
) {}
// 每天凌晨1点执行,将前一天已入住且离店日期为前一天的订单标记为已完成
@Cron('0 1 * * *')
async completeExpiredOrders() {
const yesterday = new Date();
yesterday.setDate(yesterday.getDate() - 1);
const yesterdayStr = yesterday.toISOString().split('T')[0];
const result = await this.orderRepo
.createQueryBuilder()
.update(Order)
.set({ status: 'completed', checkoutAt: new Date() })
.where('status = :status', { status: 'checked_in' })
.andWhere('DATE(checkOutDate) = :date', { date: yesterdayStr })
.execute();
if (result.affected && result.affected > 0) {
this.logger.log(
`已自动完成 ${result.affected} 笔过期订单 (${yesterdayStr})`,
);
}
}
}
@@ -0,0 +1,16 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { Order } from '@/entities/order.entity';
import { OrderSchedule } from './order.schedule';
import { SettlementSchedule } from './settlement.schedule';
import { FinanceModule } from '@/modules/finance/finance.module';
@Module({
imports: [
TypeOrmModule.forFeature([Order]),
FinanceModule,
],
providers: [OrderSchedule, SettlementSchedule],
exports: [OrderSchedule, SettlementSchedule],
})
export class ScheduleModule {}
@@ -0,0 +1,38 @@
import { Injectable } from '@nestjs/common';
import { Cron } from '@nestjs/schedule';
import { FinanceService } from '@/modules/finance/finance.service';
@Injectable()
export class SettlementSchedule {
constructor(private readonly financeService: FinanceService) {}
// 每周日凌晨1点生成上周对账单
@Cron('0 1 * * 0')
async handleWeeklySettlement() {
const now = new Date();
// 上周日~上周六
const dayOfWeek = now.getDay(); // 0=Sunday
const lastSunday = new Date(now);
lastSunday.setDate(now.getDate() - dayOfWeek - 7);
const lastSaturday = new Date(lastSunday);
lastSaturday.setDate(lastSunday.getDate() + 6);
const periodStart = lastSunday.toISOString().split('T')[0];
const periodEnd = lastSaturday.toISOString().split('T')[0];
console.log(
`[SettlementSchedule] 开始生成对账单: ${periodStart} ~ ${periodEnd}`,
);
try {
const results = await this.financeService.generateSettlements(
periodStart,
periodEnd,
);
console.log(
`[SettlementSchedule] 生成完成,共 ${results.length} 条对账单`,
);
} catch (err) {
console.error('[SettlementSchedule] 生成对账单失败:', err.message);
}
}
}