feat: realistic face avatar + voice input + ASR endpoint
This commit is contained in:
@@ -25,6 +25,7 @@ import { DailyQuestionModule } from './modules/daily-question/daily-question.mod
|
||||
import { ScheduleModule } from './modules/schedule/schedule.module'
|
||||
import { TtsModule } from './modules/tts/tts.module'
|
||||
import { PricingModule } from './modules/schemas/pricing.module'
|
||||
import { ShareModule } from './modules/share/share.module'
|
||||
|
||||
const MONGODB_URI = process.env.MONGODB_URI || 'mongodb://localhost:27017/zhiyin'
|
||||
|
||||
@@ -38,7 +39,7 @@ const MONGODB_URI = process.env.MONGODB_URI || 'mongodb://localhost:27017/zhiyin
|
||||
}),
|
||||
ThrottlerModule.forRoot([{
|
||||
ttl: 60000,
|
||||
limit: 10,
|
||||
limit: 100,
|
||||
}]),
|
||||
NestScheduleModule.forRoot(),
|
||||
UserModule,
|
||||
@@ -58,6 +59,7 @@ const MONGODB_URI = process.env.MONGODB_URI || 'mongodb://localhost:27017/zhiyin
|
||||
ScheduleModule,
|
||||
TtsModule,
|
||||
PricingModule,
|
||||
ShareModule,
|
||||
],
|
||||
providers: [
|
||||
JwtStrategy,
|
||||
|
||||
@@ -6,9 +6,12 @@ import { AdminGuard } from '../../common/guards/admin.guard'
|
||||
import { CurrentUser } from '../../common/decorators/current-user.decorator'
|
||||
import { User, UserDocument } from '../user/user.schema'
|
||||
import { Interview, InterviewDocument } from '../interview/interview.schema'
|
||||
import { Resume, ResumeDocument } from '../resume/resume.schema'
|
||||
import { PaymentOrder, PaymentOrderDocument } from '../payment/payment-order.schema'
|
||||
import { SiteConfig, SiteConfigDocument } from '../schemas/site-config.schema'
|
||||
import { ShareRecord, ShareRecordDocument, ShareVisit, ShareVisitDocument } from '../share/share.schema'
|
||||
import { QuotaService } from '../user/quota.service'
|
||||
import { PricingService } from '../schemas/pricing.service'
|
||||
import { WechatPayService } from '../payment/wechat-pay.service'
|
||||
|
||||
const VIP_DURATION_DAYS = 30
|
||||
@@ -21,7 +24,11 @@ export class AdminController {
|
||||
@InjectModel(Interview.name) private interviewModel: Model<InterviewDocument>,
|
||||
@InjectModel(PaymentOrder.name) private orderModel: Model<PaymentOrderDocument>,
|
||||
@InjectModel(SiteConfig.name) private configModel: Model<SiteConfigDocument>,
|
||||
@InjectModel(ShareRecord.name) private shareModel: Model<ShareRecordDocument>,
|
||||
@InjectModel(ShareVisit.name) private shareVisitModel: Model<ShareVisitDocument>,
|
||||
@InjectModel(Resume.name) private resumeModel: Model<ResumeDocument>,
|
||||
private quotaService: QuotaService,
|
||||
private pricingService: PricingService,
|
||||
private wechatPay: WechatPayService,
|
||||
) {}
|
||||
|
||||
@@ -38,13 +45,28 @@ export class AdminController {
|
||||
|
||||
@Get('overview')
|
||||
async overview() {
|
||||
const [userCount, interviewCount, todayUsers, todayInterviews] = await Promise.all([
|
||||
const [
|
||||
userCount, interviewCount, todayUsers, todayInterviews,
|
||||
resumeCount, paidDownloadCount,
|
||||
planStats,
|
||||
] = await Promise.all([
|
||||
this.userModel.countDocuments().exec(),
|
||||
this.interviewModel.countDocuments().exec(),
|
||||
this.userModel.countDocuments({ createdAt: { $gte: new Date(Date.now() - 24 * 60 * 60 * 1000) } }).exec(),
|
||||
this.interviewModel.countDocuments({ createdAt: { $gte: new Date(Date.now() - 24 * 60 * 60 * 1000) } }).exec(),
|
||||
this.resumeModel.countDocuments().exec(),
|
||||
this.resumeModel.countDocuments({ paidDownload: true }).exec(),
|
||||
this.userModel.aggregate([
|
||||
{ $group: { _id: '$plan', count: { $sum: 1 } } },
|
||||
]).exec(),
|
||||
])
|
||||
return { userCount, interviewCount, todayUsers, todayInterviews }
|
||||
const planBreakdown: Record<string, number> = {}
|
||||
planStats.forEach(p => { planBreakdown[p._id || 'free'] = p.count })
|
||||
return {
|
||||
userCount, interviewCount, todayUsers, todayInterviews,
|
||||
resumeCount, paidDownloadCount,
|
||||
planBreakdown,
|
||||
}
|
||||
}
|
||||
|
||||
@Get('users')
|
||||
@@ -70,7 +92,12 @@ export class AdminController {
|
||||
async getInterviews(@Query('page') page = '1', @Query('limit') limit = '20') {
|
||||
const skip = (Math.max(1, +page) - 1) * +limit
|
||||
const [interviews, total] = await Promise.all([
|
||||
this.interviewModel.find().sort({ createdAt: -1 }).skip(skip).limit(+limit).populate('userId', 'phone nickname').lean().exec(),
|
||||
this.interviewModel.find()
|
||||
.sort({ createdAt: -1 })
|
||||
.skip(skip).limit(+limit)
|
||||
.populate('userId', 'phone nickname')
|
||||
.select('position status totalScore questionCount fillerScore fillerDensity summary createdAt')
|
||||
.lean().exec(),
|
||||
this.interviewModel.countDocuments().exec(),
|
||||
])
|
||||
return { interviews, total, page: +page }
|
||||
@@ -80,14 +107,135 @@ export class AdminController {
|
||||
async setVip(@Body('userId') targetUserId: string) {
|
||||
const user = await this.userModel.findById(targetUserId).exec()
|
||||
if (!user) throw new HttpException('用户不存在', HttpStatus.NOT_FOUND)
|
||||
const pricing = await this.pricingService.getConfig()
|
||||
const credits = pricing.plans?.growth?.credits || { interview: 999, resumeOptimize: 20, resumeDownload: 10 }
|
||||
const expireAt = new Date()
|
||||
expireAt.setDate(expireAt.getDate() + VIP_DURATION_DAYS)
|
||||
expireAt.setDate(expireAt.getDate() + (pricing.plans?.growth?.durationDays || VIP_DURATION_DAYS))
|
||||
user.plan = 'growth'
|
||||
user.vipExpireAt = expireAt
|
||||
await this.quotaService.setPlanQuota(targetUserId, 'growth', { interview: 999, resumeOptimize: 20, resumeDownload: 10 })
|
||||
await this.quotaService.setPlanQuota(targetUserId, 'growth', credits)
|
||||
return { success: true, plan: 'growth', expireAt }
|
||||
}
|
||||
|
||||
@Post('user/credits')
|
||||
async adjustCredits(@Body('userId') userId: string, @Body('type') type: string, @Body('amount') amount: number) {
|
||||
if (!userId || !type || amount === undefined) {
|
||||
throw new HttpException('参数不完整', HttpStatus.BAD_REQUEST)
|
||||
}
|
||||
const validTypes = ['interviewCredits', 'resumeOptimizeCredits', 'resumeDownloadCredits', 'shareCredits']
|
||||
if (!validTypes.includes(type)) {
|
||||
throw new HttpException('无效的额度类型', HttpStatus.BAD_REQUEST)
|
||||
}
|
||||
const result = await this.userModel.findByIdAndUpdate(
|
||||
userId,
|
||||
{ $set: { [type]: Math.max(0, Math.round(amount)) } },
|
||||
).exec()
|
||||
if (!result) throw new HttpException('用户不存在', HttpStatus.NOT_FOUND)
|
||||
return { success: true }
|
||||
}
|
||||
|
||||
@Get('share-records')
|
||||
async getShareRecords(@Query('page') page = '1', @Query('limit') limit = '20') {
|
||||
const skip = (Math.max(1, +page) - 1) * +limit
|
||||
const [list, total] = await Promise.all([
|
||||
this.shareModel.aggregate([
|
||||
{ $sort: { createdAt: -1 } },
|
||||
{ $skip: skip },
|
||||
{ $limit: +limit },
|
||||
{
|
||||
$lookup: {
|
||||
from: 'users',
|
||||
localField: 'userId',
|
||||
foreignField: '_id',
|
||||
as: 'sharer',
|
||||
},
|
||||
},
|
||||
{ $unwind: { path: '$sharer', preserveNullAndEmptyArrays: true } },
|
||||
{
|
||||
$project: {
|
||||
shareCode: 1,
|
||||
type: 1,
|
||||
title: 1,
|
||||
visitCount: 1,
|
||||
creditedCount: 1,
|
||||
isActive: 1,
|
||||
createdAt: 1,
|
||||
sharer: { nickname: '$sharer.nickname', phone: '$sharer.phone', _id: '$sharer._id' },
|
||||
},
|
||||
},
|
||||
]).exec(),
|
||||
this.shareModel.countDocuments().exec(),
|
||||
])
|
||||
return { list, total, page: +page }
|
||||
}
|
||||
|
||||
@Get('share-visitors')
|
||||
async getShareVisitors(@Query('page') page = '1', @Query('limit') limit = '20') {
|
||||
const skip = (Math.max(1, +page) - 1) * +limit
|
||||
const [list, total] = await Promise.all([
|
||||
this.shareVisitModel.aggregate([
|
||||
{ $sort: { createdAt: -1 } },
|
||||
{ $skip: skip },
|
||||
{ $limit: +limit },
|
||||
{
|
||||
$lookup: {
|
||||
from: 'users',
|
||||
localField: 'sharerId',
|
||||
foreignField: '_id',
|
||||
as: 'sharer',
|
||||
},
|
||||
},
|
||||
{ $unwind: { path: '$sharer', preserveNullAndEmptyArrays: true } },
|
||||
{
|
||||
$lookup: {
|
||||
from: 'users',
|
||||
localField: 'visitorUserId',
|
||||
foreignField: '_id',
|
||||
as: 'visitorUser',
|
||||
},
|
||||
},
|
||||
{ $unwind: { path: '$visitorUser', preserveNullAndEmptyArrays: true } },
|
||||
{
|
||||
$project: {
|
||||
credited: 1,
|
||||
creditedAt: 1,
|
||||
createdAt: 1,
|
||||
sharer: { nickname: '$sharer.nickname', phone: '$sharer.phone' },
|
||||
visitor: { nickname: { $ifNull: ['$visitorUser.nickname', '匿名'] }, phone: { $ifNull: ['$visitorUser.phone', ''] } },
|
||||
},
|
||||
},
|
||||
]).exec(),
|
||||
this.shareVisitModel.countDocuments().exec(),
|
||||
])
|
||||
return { list, total, page: +page }
|
||||
}
|
||||
|
||||
@Get('resumes')
|
||||
async getResumes(@Query('page') page = '1', @Query('limit') limit = '20') {
|
||||
const skip = (Math.max(1, +page) - 1) * +limit
|
||||
const [list, total] = await Promise.all([
|
||||
this.resumeModel.find()
|
||||
.sort({ createdAt: -1 })
|
||||
.skip(skip).limit(+limit)
|
||||
.populate('userId', 'phone nickname')
|
||||
.select('title targetPosition version paidDownload createdAt')
|
||||
.lean().exec(),
|
||||
this.resumeModel.countDocuments().exec(),
|
||||
])
|
||||
return { list, total, page: +page }
|
||||
}
|
||||
|
||||
@Get('user/:id')
|
||||
async getUserDetail(@Query('id') id: string) {
|
||||
const user = await this.userModel.findById(id).select('-password -openid').lean().exec()
|
||||
if (!user) throw new HttpException('用户不存在', HttpStatus.NOT_FOUND)
|
||||
const [interviews, resumes] = await Promise.all([
|
||||
this.interviewModel.find({ userId: id }).sort({ createdAt: -1 }).limit(10).select('position status totalScore questionCount createdAt').lean().exec(),
|
||||
this.resumeModel.find({ userId: id }).sort({ createdAt: -1 }).limit(10).select('title targetPosition version paidDownload createdAt').lean().exec(),
|
||||
])
|
||||
return { user, interviews, resumes }
|
||||
}
|
||||
|
||||
@Get('admins')
|
||||
async getAdmins() {
|
||||
const admins = await this.userModel.find({ role: 'admin' }).select('phone nickname email createdAt isSystemAdmin').lean().exec()
|
||||
@@ -130,14 +278,22 @@ export class AdminController {
|
||||
if (order.type === 'membership') {
|
||||
const user = await this.userModel.findById(order.userId).exec()
|
||||
if (user && user.plan === 'free') {
|
||||
const pricing = await this.pricingService.getConfig()
|
||||
const credits = pricing.plans?.growth?.credits || { interview: 999, resumeOptimize: 20, resumeDownload: 10 }
|
||||
const expireAt = new Date()
|
||||
expireAt.setDate(expireAt.getDate() + VIP_DURATION_DAYS)
|
||||
expireAt.setDate(expireAt.getDate() + (pricing.plans?.growth?.durationDays || VIP_DURATION_DAYS))
|
||||
user.plan = 'growth'
|
||||
user.vipExpireAt = expireAt
|
||||
await this.quotaService.setPlanQuota(order.userId, 'growth', { interview: 999, resumeOptimize: 20, resumeDownload: 10 })
|
||||
await this.quotaService.setPlanQuota(order.userId, 'growth', credits)
|
||||
}
|
||||
} else {
|
||||
const credits = { interview: 1, optimize: 1, download: 1 }[order.type]
|
||||
const pricing = await this.pricingService.getConfig()
|
||||
const creditMap: Record<string, number> = {
|
||||
interview: pricing.interview?.creditsPerPurchase || 1,
|
||||
optimize: pricing.resumeOptimize?.creditsPerPurchase || 1,
|
||||
download: pricing.resumeDownload?.creditsPerPurchase || 1,
|
||||
}
|
||||
const credits = creditMap[order.type]
|
||||
if (credits) {
|
||||
await this.quotaService.grantCredits(order.userId, order.type as any, credits)
|
||||
}
|
||||
@@ -179,6 +335,7 @@ export class AdminController {
|
||||
{ key: 'pricing', value: body, description: '定价配置' },
|
||||
{ upsert: true },
|
||||
).exec()
|
||||
this.pricingService.invalidateCache()
|
||||
return { success: true }
|
||||
}
|
||||
|
||||
|
||||
@@ -8,6 +8,8 @@ import { PaymentOrder, PaymentOrderSchema } from '../payment/payment-order.schem
|
||||
import { WechatPayService } from '../payment/wechat-pay.service'
|
||||
import { AdminGuard } from '../../common/guards/admin.guard'
|
||||
import { SiteConfig, SiteConfigSchema } from '../schemas/site-config.schema'
|
||||
import { ShareRecord, ShareRecordSchema, ShareVisit, ShareVisitSchema } from '../share/share.schema'
|
||||
import { Resume, ResumeSchema } from '../resume/resume.schema'
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
@@ -16,6 +18,9 @@ import { SiteConfig, SiteConfigSchema } from '../schemas/site-config.schema'
|
||||
{ name: Interview.name, schema: InterviewSchema },
|
||||
{ name: PaymentOrder.name, schema: PaymentOrderSchema },
|
||||
{ name: SiteConfig.name, schema: SiteConfigSchema },
|
||||
{ name: ShareRecord.name, schema: ShareRecordSchema },
|
||||
{ name: ShareVisit.name, schema: ShareVisitSchema },
|
||||
{ name: Resume.name, schema: ResumeSchema },
|
||||
]),
|
||||
UserModule,
|
||||
],
|
||||
|
||||
@@ -168,6 +168,8 @@ export class PaymentController {
|
||||
order.wxTransactionId = wxTransactionId
|
||||
order.description = `${decrypted.trade_type || ''} 支付成功`
|
||||
await order.save()
|
||||
} else {
|
||||
return { code: 'SUCCESS', message: '已处理' }
|
||||
}
|
||||
|
||||
if (order.type === 'membership') {
|
||||
@@ -260,6 +262,15 @@ export class PaymentController {
|
||||
return { success: true, plan: order.plan }
|
||||
}
|
||||
|
||||
// For product orders, check if already activated via callback
|
||||
const user = await this.userModel.findById(userId).exec()
|
||||
if (user && order.type === 'interview' && (user.interviewCredits || 0) > 0) {
|
||||
return { success: true, type: order.type, alreadyActivated: true }
|
||||
}
|
||||
if (user && order.type === 'optimize' && (user.resumeOptimizeCredits || 0) > 0) {
|
||||
return { success: true, type: order.type, alreadyActivated: true }
|
||||
}
|
||||
|
||||
await this.activateProduct(order)
|
||||
return { success: true, type: order.type }
|
||||
}
|
||||
|
||||
@@ -31,13 +31,18 @@ export class ResumePdfService {
|
||||
}
|
||||
}
|
||||
|
||||
private escapeHtml(str: string): string {
|
||||
return str.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')
|
||||
.replace(/"/g, '"').replace(/'/g, ''')
|
||||
}
|
||||
|
||||
private buildHtml(params: {
|
||||
title: string
|
||||
content: string
|
||||
targetPosition?: string
|
||||
userName?: string
|
||||
}): string {
|
||||
const contentHtml = params.content
|
||||
const contentHtml = this.escapeHtml(params.content)
|
||||
.replace(/\n/g, '<br>')
|
||||
.replace(/### (.+)/g, '<h3>$1</h3>')
|
||||
.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>')
|
||||
@@ -66,8 +71,8 @@ export class ResumePdfService {
|
||||
</head>
|
||||
<body>
|
||||
<div class="page">
|
||||
<h1>${params.title}</h1>
|
||||
<div class="subtitle">${params.targetPosition ? `目标岗位: ${params.targetPosition}` : ''}</div>
|
||||
<h1>${this.escapeHtml(params.title)}</h1>
|
||||
<div class="subtitle">${params.targetPosition ? `目标岗位: ${this.escapeHtml(params.targetPosition)}` : ''}</div>
|
||||
<div class="content">${contentHtml}</div>
|
||||
<div class="footer">由 AI磁场·职引 生成</div>
|
||||
</div>
|
||||
|
||||
@@ -31,13 +31,10 @@ export class ResumeController {
|
||||
@Post(':id/download')
|
||||
async download(@Param('id') id: string, @CurrentUser('userId') userId: string, @Res() res: Response) {
|
||||
const resume = await this.resumeService.getDetail(id, userId)
|
||||
|
||||
const canDownload = await this.quotaService.checkDownload(userId, resume)
|
||||
if (!canDownload) {
|
||||
const canDownload = await this.quotaService.checkAndDeductDownload(userId, resume.paidDownload)
|
||||
if (!canDownload && !resume.paidDownload) {
|
||||
throw new HttpException('请先付费下载', HttpStatus.PAYMENT_REQUIRED)
|
||||
}
|
||||
|
||||
await this.quotaService.deductDownload(userId, resume)
|
||||
if (!resume.paidDownload) {
|
||||
await this.resumeService.markPaid(id, userId)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
import { Controller, Get, Post, Body, Param, Query, HttpException, HttpStatus, UseGuards } from '@nestjs/common'
|
||||
import { JwtService } from '@nestjs/jwt'
|
||||
import { ShareService } from './share.service'
|
||||
import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard'
|
||||
import { CurrentUser } from '../../common/decorators/current-user.decorator'
|
||||
import { Public } from '../../common/decorators/public.decorator'
|
||||
|
||||
@Controller('share')
|
||||
export class ShareController {
|
||||
constructor(
|
||||
private shareService: ShareService,
|
||||
private jwtService: JwtService,
|
||||
) {}
|
||||
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@Post('create')
|
||||
async create(
|
||||
@CurrentUser('userId') userId: string,
|
||||
@Body() body: { type: string; refId?: string; title?: string; description?: string },
|
||||
) {
|
||||
return this.shareService.create(userId, body)
|
||||
}
|
||||
|
||||
@Public()
|
||||
@Get('visit/:shareCode')
|
||||
async visit(
|
||||
@Param('shareCode') shareCode: string,
|
||||
@Query('visitorId') visitorId?: string,
|
||||
@Query('token') token?: string,
|
||||
) {
|
||||
if (!visitorId) {
|
||||
throw new HttpException('缺少访问者标识', HttpStatus.BAD_REQUEST)
|
||||
}
|
||||
let visitorUserId: string | undefined
|
||||
if (token) {
|
||||
try {
|
||||
const payload = this.jwtService.verify(token) as any
|
||||
visitorUserId = payload.userId
|
||||
} catch {}
|
||||
}
|
||||
return this.shareService.visit(shareCode, visitorId, visitorUserId)
|
||||
}
|
||||
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@Get('stats')
|
||||
async stats(@CurrentUser('userId') userId: string) {
|
||||
return this.shareService.stats(userId)
|
||||
}
|
||||
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@Get('records')
|
||||
async records(
|
||||
@CurrentUser('userId') userId: string,
|
||||
@Query('page') page?: string,
|
||||
@Query('pageSize') pageSize?: string,
|
||||
) {
|
||||
return this.shareService.records(userId, Number(page) || 1, Number(pageSize) || 20)
|
||||
}
|
||||
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@Get('visitors')
|
||||
async visitors(
|
||||
@CurrentUser('userId') userId: string,
|
||||
@Query('page') page?: string,
|
||||
@Query('pageSize') pageSize?: string,
|
||||
) {
|
||||
return this.shareService.visitors(userId, Number(page) || 1, Number(pageSize) || 20)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import { Module } from '@nestjs/common'
|
||||
import { JwtModule } from '@nestjs/jwt'
|
||||
import { MongooseModule } from '@nestjs/mongoose'
|
||||
import { ShareController } from './share.controller'
|
||||
import { ShareService } from './share.service'
|
||||
import { ShareRecord, ShareRecordSchema, ShareVisit, ShareVisitSchema } from './share.schema'
|
||||
import { UserModule } from '../user/user.module'
|
||||
import { User, UserSchema } from '../user/user.schema'
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
MongooseModule.forFeature([
|
||||
{ name: ShareRecord.name, schema: ShareRecordSchema },
|
||||
{ name: ShareVisit.name, schema: ShareVisitSchema },
|
||||
{ name: User.name, schema: UserSchema },
|
||||
]),
|
||||
UserModule,
|
||||
JwtModule.register({
|
||||
secret: process.env.JWT_SECRET,
|
||||
signOptions: { expiresIn: '7d' },
|
||||
}),
|
||||
],
|
||||
controllers: [ShareController],
|
||||
providers: [ShareService],
|
||||
})
|
||||
export class ShareModule {}
|
||||
@@ -0,0 +1,72 @@
|
||||
import { Prop, Schema, SchemaFactory } from '@nestjs/mongoose'
|
||||
import { Document, Types } from 'mongoose'
|
||||
|
||||
export type ShareRecordDocument = ShareRecord & Document
|
||||
export type ShareVisitDocument = ShareVisit & Document
|
||||
|
||||
@Schema({ timestamps: true })
|
||||
export class ShareRecord {
|
||||
@Prop({ type: Types.ObjectId, ref: 'User', required: true })
|
||||
userId: Types.ObjectId
|
||||
|
||||
@Prop({ required: true, unique: true })
|
||||
shareCode: string
|
||||
|
||||
@Prop({ default: 'app' })
|
||||
type: string
|
||||
|
||||
@Prop({ default: '' })
|
||||
refId: string
|
||||
|
||||
@Prop({ default: '' })
|
||||
title: string
|
||||
|
||||
@Prop({ default: '' })
|
||||
description: string
|
||||
|
||||
@Prop({ default: 'link' })
|
||||
channel: string
|
||||
|
||||
@Prop({ default: 0 })
|
||||
visitCount: number
|
||||
|
||||
@Prop({ default: 0 })
|
||||
creditedCount: number
|
||||
|
||||
@Prop({ default: true })
|
||||
isActive: boolean
|
||||
|
||||
readonly createdAt?: Date
|
||||
readonly updatedAt?: Date
|
||||
}
|
||||
|
||||
@Schema({ timestamps: true })
|
||||
export class ShareVisit {
|
||||
@Prop({ type: Types.ObjectId, ref: 'ShareRecord', required: true })
|
||||
shareId: Types.ObjectId
|
||||
|
||||
@Prop({ type: Types.ObjectId, ref: 'User', required: true })
|
||||
sharerId: Types.ObjectId
|
||||
|
||||
@Prop({ required: true })
|
||||
visitorId: string
|
||||
|
||||
@Prop({ type: Types.ObjectId, ref: 'User' })
|
||||
visitorUserId: Types.ObjectId
|
||||
|
||||
@Prop({ default: false })
|
||||
credited: boolean
|
||||
|
||||
@Prop()
|
||||
creditedAt?: Date
|
||||
|
||||
readonly createdAt?: Date
|
||||
readonly updatedAt?: Date
|
||||
}
|
||||
|
||||
export const ShareRecordSchema = SchemaFactory.createForClass(ShareRecord)
|
||||
export const ShareVisitSchema = SchemaFactory.createForClass(ShareVisit)
|
||||
|
||||
ShareRecordSchema.index({ userId: 1, createdAt: -1 })
|
||||
ShareVisitSchema.index({ shareId: 1, visitorId: 1 }, { unique: true })
|
||||
ShareVisitSchema.index({ sharerId: 1, createdAt: -1 })
|
||||
@@ -0,0 +1,186 @@
|
||||
import * as crypto from 'crypto'
|
||||
import { Injectable, HttpException, HttpStatus, Logger } from '@nestjs/common'
|
||||
import { InjectModel } from '@nestjs/mongoose'
|
||||
import { Model, Types } from 'mongoose'
|
||||
import { ShareRecord, ShareRecordDocument, ShareVisit, ShareVisitDocument } from './share.schema'
|
||||
import { QuotaService } from '../user/quota.service'
|
||||
import { User, UserDocument } from '../user/user.schema'
|
||||
|
||||
const DAILY_LIMIT = 3
|
||||
const LINK_TTL_DAYS = 30
|
||||
|
||||
@Injectable()
|
||||
export class ShareService {
|
||||
private readonly logger = new Logger(ShareService.name)
|
||||
|
||||
constructor(
|
||||
@InjectModel(ShareRecord.name) private shareModel: Model<ShareRecordDocument>,
|
||||
@InjectModel(ShareVisit.name) private visitModel: Model<ShareVisitDocument>,
|
||||
@InjectModel(User.name) private userModel: Model<UserDocument>,
|
||||
private quotaService: QuotaService,
|
||||
) {}
|
||||
|
||||
async create(userId: string, body: { type: string; refId?: string; title?: string; description?: string }) {
|
||||
const shareCode = crypto.randomBytes(4).toString('hex')
|
||||
const record = await this.shareModel.create({
|
||||
userId: new Types.ObjectId(userId),
|
||||
shareCode,
|
||||
type: body.type || 'app',
|
||||
refId: body.refId || '',
|
||||
title: body.title || '我在职引发现了好东西',
|
||||
description: body.description || '快来一起体验吧',
|
||||
})
|
||||
return {
|
||||
shareCode: record.shareCode,
|
||||
shareUrl: `/share/${record.shareCode}`,
|
||||
wechatShareInfo: {
|
||||
title: record.title,
|
||||
description: record.description,
|
||||
path: `/pages/share/share?code=${record.shareCode}`,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
async visit(shareCode: string, visitorId: string, visitorUserId?: string) {
|
||||
const share = await this.shareModel.findOne({ shareCode, isActive: true }).exec()
|
||||
if (!share) throw new HttpException('分享链接不存在或已失效', HttpStatus.NOT_FOUND)
|
||||
|
||||
await this.shareModel.findByIdAndUpdate(share._id, { $inc: { visitCount: 1 } }).exec()
|
||||
|
||||
const sharerIdStr = share.userId.toString()
|
||||
if (!visitorUserId || visitorUserId === sharerIdStr) {
|
||||
return { sharer: true, visitorUserId }
|
||||
}
|
||||
|
||||
const existing = await this.visitModel.findOne({
|
||||
shareId: share._id,
|
||||
visitorId,
|
||||
}).exec()
|
||||
if (existing) return { alreadyVisited: true, visitorUserId }
|
||||
|
||||
await this.visitModel.create({
|
||||
shareId: share._id,
|
||||
sharerId: share.userId,
|
||||
visitorId,
|
||||
visitorUserId: new Types.ObjectId(visitorUserId),
|
||||
}).catch(() => {})
|
||||
|
||||
const alreadyCredited = await this.visitModel.findOne({
|
||||
shareId: share._id,
|
||||
visitorId,
|
||||
credited: true,
|
||||
}).exec()
|
||||
if (alreadyCredited) return { credited: true, visitorUserId }
|
||||
|
||||
const todayStart = new Date()
|
||||
todayStart.setHours(0, 0, 0, 0)
|
||||
|
||||
const todayCredited = await this.visitModel.countDocuments({
|
||||
sharerId: share.userId,
|
||||
credited: true,
|
||||
creditedAt: { $gte: todayStart },
|
||||
}).exec()
|
||||
|
||||
if (todayCredited >= DAILY_LIMIT) return { dailyLimitReached: true, visitorUserId }
|
||||
|
||||
const shareCreditsResult = await this.quotaService.grantShareCredits(sharerIdStr)
|
||||
if (!shareCreditsResult) return { creditFailed: true, visitorUserId }
|
||||
|
||||
await this.visitModel.updateOne(
|
||||
{ shareId: share._id, visitorId },
|
||||
{ $set: { credited: true, creditedAt: new Date() } },
|
||||
).exec()
|
||||
|
||||
await this.shareModel.findByIdAndUpdate(share._id, { $inc: { creditedCount: 1 } }).exec()
|
||||
|
||||
return { credited: true, visitorUserId }
|
||||
}
|
||||
|
||||
async stats(userId: string) {
|
||||
const todayStart = new Date()
|
||||
todayStart.setHours(0, 0, 0, 0)
|
||||
|
||||
const [totalShares, visitAgg, todayAgg, user] = await Promise.all([
|
||||
this.shareModel.countDocuments({ userId: new Types.ObjectId(userId) }).exec(),
|
||||
this.visitModel.aggregate([
|
||||
{ $match: { sharerId: new Types.ObjectId(userId) } },
|
||||
{
|
||||
$group: {
|
||||
_id: null,
|
||||
totalVisits: { $sum: 1 },
|
||||
creditedCount: { $sum: { $cond: ['$credited', 1, 0] } },
|
||||
},
|
||||
},
|
||||
]).exec(),
|
||||
this.visitModel.countDocuments({
|
||||
sharerId: new Types.ObjectId(userId),
|
||||
credited: true,
|
||||
creditedAt: { $gte: todayStart },
|
||||
}).exec(),
|
||||
this.userModel.findById(userId).exec(),
|
||||
])
|
||||
|
||||
return {
|
||||
totalShares,
|
||||
totalVisits: visitAgg[0]?.totalVisits ?? 0,
|
||||
creditedCount: visitAgg[0]?.creditedCount ?? 0,
|
||||
todayCredited: todayAgg,
|
||||
shareCredits: user?.shareCredits ?? 0,
|
||||
}
|
||||
}
|
||||
|
||||
async records(userId: string, page = 1, pageSize = 20) {
|
||||
const list = await this.shareModel.find({ userId: new Types.ObjectId(userId) })
|
||||
.sort({ createdAt: -1 })
|
||||
.skip((page - 1) * pageSize)
|
||||
.limit(pageSize)
|
||||
.exec()
|
||||
const total = await this.shareModel.countDocuments({ userId: new Types.ObjectId(userId) }).exec()
|
||||
return {
|
||||
list: list.map(r => ({
|
||||
shareCode: r.shareCode,
|
||||
type: r.type,
|
||||
title: r.title,
|
||||
visitCount: r.visitCount,
|
||||
creditedCount: r.creditedCount,
|
||||
createdAt: r.createdAt,
|
||||
})),
|
||||
total,
|
||||
page,
|
||||
pageSize,
|
||||
}
|
||||
}
|
||||
|
||||
async visitors(userId: string, page = 1, pageSize = 20) {
|
||||
const [list, total] = await Promise.all([
|
||||
this.visitModel.aggregate([
|
||||
{ $match: { sharerId: new Types.ObjectId(userId) } },
|
||||
{ $sort: { createdAt: -1 } },
|
||||
{ $skip: (page - 1) * pageSize },
|
||||
{ $limit: pageSize },
|
||||
{
|
||||
$lookup: {
|
||||
from: 'users',
|
||||
localField: 'visitorUserId',
|
||||
foreignField: '_id',
|
||||
as: 'visitorUser',
|
||||
},
|
||||
},
|
||||
{ $unwind: { path: '$visitorUser', preserveNullAndEmptyArrays: true } },
|
||||
{
|
||||
$project: {
|
||||
_id: 0,
|
||||
visitorId: 1,
|
||||
credited: 1,
|
||||
creditedAt: 1,
|
||||
createdAt: 1,
|
||||
nickname: { $ifNull: ['$visitorUser.nickname', '匿名用户'] },
|
||||
avatar: { $ifNull: ['$visitorUser.avatar', ''] },
|
||||
},
|
||||
},
|
||||
]).exec(),
|
||||
this.visitModel.countDocuments({ sharerId: new Types.ObjectId(userId) }).exec(),
|
||||
])
|
||||
return { list, total, page, pageSize }
|
||||
}
|
||||
}
|
||||
@@ -1,14 +1,18 @@
|
||||
import { Controller, Get, Post, Body, Param, Res, HttpException, HttpStatus } from '@nestjs/common'
|
||||
import { Controller, Get, Post, Body, Param, Res, HttpException, HttpStatus, UseGuards, UploadedFile, UseInterceptors } from '@nestjs/common'
|
||||
import { FileInterceptor } from '@nestjs/platform-express'
|
||||
import { Response } from 'express'
|
||||
import * as fs from 'fs'
|
||||
import * as path from 'path'
|
||||
import { execSync } from 'child_process'
|
||||
import { TtsService } from './tts.service'
|
||||
import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard'
|
||||
import { Public } from '../../common/decorators/public.decorator'
|
||||
|
||||
@Controller('tts')
|
||||
export class TtsController {
|
||||
constructor(private ttsService: TtsService) {}
|
||||
|
||||
@Public()
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@Post('synthesize')
|
||||
async synthesize(@Body('text') text: string, @Body('voice') voice?: string) {
|
||||
if (!text || text.length > 500) {
|
||||
@@ -30,4 +34,36 @@ export class TtsController {
|
||||
res.setHeader('Cache-Control', 'public, max-age=31536000')
|
||||
stream.pipe(res)
|
||||
}
|
||||
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@Post('asr')
|
||||
@UseInterceptors(FileInterceptor('audio', { dest: '/tmp/asr_uploads' }))
|
||||
async recognize(@UploadedFile() file: any) {
|
||||
if (!file) throw new HttpException('请上传音频文件', HttpStatus.BAD_REQUEST)
|
||||
const uploadDir = '/tmp/asr_uploads'
|
||||
if (!fs.existsSync(uploadDir)) fs.mkdirSync(uploadDir, { recursive: true })
|
||||
const ext = path.extname(file.originalname) || '.mp3'
|
||||
const dest = path.join(uploadDir, file.filename + ext)
|
||||
fs.renameSync(file.path, dest)
|
||||
try {
|
||||
if (process.env.OPENAI_API_KEY) {
|
||||
const result = execSync(
|
||||
`curl -s -X POST https://api.openai.com/v1/audio/transcriptions \
|
||||
-H "Authorization: Bearer ${process.env.OPENAI_API_KEY}" \
|
||||
-H "Content-Type: multipart/form-data" \
|
||||
-F "file=@${dest}" \
|
||||
-F "model=whisper-1" \
|
||||
-F "language=zh"`,
|
||||
{ encoding: 'utf8', timeout: 30000 },
|
||||
)
|
||||
const parsed = JSON.parse(result)
|
||||
if (parsed.text) return { text: parsed.text.trim() }
|
||||
}
|
||||
const whisperResult = execSync(`whisper "${dest}" --language zh --output_format txt 2>/dev/null`, { encoding: 'utf8', timeout: 60000 })
|
||||
if (whisperResult && whisperResult.trim()) {
|
||||
return { text: whisperResult.trim() }
|
||||
}
|
||||
} catch {}
|
||||
return { text: '' }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,6 +12,18 @@ interface TtsResult {
|
||||
durationMs: number
|
||||
}
|
||||
|
||||
const VALID_VOICES = new Set([
|
||||
'zh-CN-XiaoxiaoNeural', 'zh-CN-XiaoyiNeural', 'zh-CN-YunjianNeural',
|
||||
'zh-CN-YunxiNeural', 'zh-CN-YunxiaNeural', 'zh-CN-YunyangNeural',
|
||||
'zh-CN-liaoning-XiaobeiNeural', 'zh-CN-shaanxi-XiaoniNeural',
|
||||
])
|
||||
|
||||
function validateVoice(voice: string): void {
|
||||
if (!VALID_VOICES.has(voice)) {
|
||||
throw new Error(`不支持的语音: ${voice}`)
|
||||
}
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class TtsService {
|
||||
private readonly logger = new Logger(TtsService.name)
|
||||
@@ -23,6 +35,7 @@ export class TtsService {
|
||||
}
|
||||
|
||||
async synthesize(text: string, voice: string = 'zh-CN-XiaoxiaoNeural'): Promise<TtsResult> {
|
||||
validateVoice(voice)
|
||||
const hash = crypto.createHash('md5').update(text + voice).digest('hex')
|
||||
const filePath = path.join(CACHE_DIR, `${hash}.mp3`)
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Injectable, HttpException, HttpStatus } from '@nestjs/common'
|
||||
import { Injectable, HttpException, HttpStatus, Logger } from '@nestjs/common'
|
||||
import { InjectModel } from '@nestjs/mongoose'
|
||||
import { Model } from 'mongoose'
|
||||
import { User, UserDocument } from './user.schema'
|
||||
@@ -7,6 +7,8 @@ const FREE_OPTIMIZE_LIMIT = 3
|
||||
|
||||
@Injectable()
|
||||
export class QuotaService {
|
||||
private readonly logger = new Logger(QuotaService.name)
|
||||
|
||||
constructor(
|
||||
@InjectModel(User.name) private userModel: Model<UserDocument>,
|
||||
) {}
|
||||
@@ -16,12 +18,28 @@ export class QuotaService {
|
||||
if (!user) throw new HttpException('用户不存在', HttpStatus.NOT_FOUND)
|
||||
if (user.plan !== 'free') return
|
||||
|
||||
if ((user.interviewCredits || 0) <= 0) {
|
||||
throw new HttpException('面试次数已用完,请购买面试次数或开通会员', HttpStatus.FORBIDDEN)
|
||||
// Backward compat: migrate remaining → interviewCredits
|
||||
if ((user.interviewCredits ?? 0) <= 0 && (user.remaining ?? 0) > 0) {
|
||||
await this.userModel.findByIdAndUpdate(userId, {
|
||||
$set: { interviewCredits: user.remaining, remaining: 0 },
|
||||
}).exec()
|
||||
}
|
||||
user.interviewCredits = (user.interviewCredits || 0) - 1
|
||||
user.interviewCount = (user.interviewCount || 0) + 1
|
||||
await user.save()
|
||||
|
||||
const result = await this.userModel.findOneAndUpdate(
|
||||
{ _id: userId, interviewCredits: { $gt: 0 } },
|
||||
{ $inc: { interviewCredits: -1, interviewCount: 1 } },
|
||||
{ new: true },
|
||||
).exec()
|
||||
if (result) return
|
||||
|
||||
// Fallback to share credits
|
||||
const shareResult = await this.userModel.findOneAndUpdate(
|
||||
{ _id: userId, shareCredits: { $gt: 0 } },
|
||||
{ $inc: { shareCredits: -1, interviewCount: 1 } },
|
||||
).exec()
|
||||
if (shareResult) return
|
||||
|
||||
throw new HttpException('面试次数已用完,请购买面试次数或开通会员', HttpStatus.FORBIDDEN)
|
||||
}
|
||||
|
||||
async checkAndDeductOptimize(userId: string) {
|
||||
@@ -29,64 +47,85 @@ export class QuotaService {
|
||||
if (!user) throw new HttpException('用户不存在', HttpStatus.NOT_FOUND)
|
||||
if (user.plan !== 'free') return
|
||||
|
||||
// 优先扣付费额度
|
||||
if ((user.resumeOptimizeCredits || 0) > 0) {
|
||||
user.resumeOptimizeCredits = (user.resumeOptimizeCredits || 0) - 1
|
||||
await user.save()
|
||||
return
|
||||
// Backward compat: migrate remaining → freeOptimizeUsed
|
||||
if ((user.freeOptimizeUsed ?? 0) <= 0 && (user.remaining ?? 0) > 0 && (user.resumeOptimizeCredits ?? 0) <= 0) {
|
||||
const migrateCount = Math.min(user.remaining, FREE_OPTIMIZE_LIMIT)
|
||||
await this.userModel.findByIdAndUpdate(userId, {
|
||||
$set: { freeOptimizeUsed: migrateCount, remaining: Math.max(0, user.remaining - migrateCount) },
|
||||
}).exec()
|
||||
this.logger.log(`Migrated remaining=${user.remaining} → freeOptimizeUsed=${migrateCount} for user ${userId}`)
|
||||
}
|
||||
|
||||
// 免费额度
|
||||
if ((user.freeOptimizeUsed || 0) < FREE_OPTIMIZE_LIMIT) {
|
||||
user.freeOptimizeUsed = (user.freeOptimizeUsed || 0) + 1
|
||||
await user.save()
|
||||
return
|
||||
}
|
||||
// Try paid credits first
|
||||
const paid = await this.userModel.findOneAndUpdate(
|
||||
{ _id: userId, resumeOptimizeCredits: { $gt: 0 } },
|
||||
{ $inc: { resumeOptimizeCredits: -1 } },
|
||||
).exec()
|
||||
if (paid) return
|
||||
|
||||
// Then free limit
|
||||
const freeResult = await this.userModel.findOneAndUpdate(
|
||||
{ _id: userId, freeOptimizeUsed: { $lt: FREE_OPTIMIZE_LIMIT } },
|
||||
{ $inc: { freeOptimizeUsed: 1 } },
|
||||
).exec()
|
||||
if (freeResult) return
|
||||
|
||||
// Fallback to share credits
|
||||
const shareResult = await this.userModel.findOneAndUpdate(
|
||||
{ _id: userId, shareCredits: { $gt: 0 } },
|
||||
{ $inc: { shareCredits: -1 } },
|
||||
).exec()
|
||||
if (shareResult) return
|
||||
|
||||
throw new HttpException('简历优化次数已用完,请购买优化次数或开通会员', HttpStatus.FORBIDDEN)
|
||||
}
|
||||
|
||||
async checkDownload(userId: string, resume: { paidDownload?: boolean }): Promise<boolean> {
|
||||
const user = await this.userModel.findById(userId).exec()
|
||||
if (!user) throw new HttpException('用户不存在', HttpStatus.NOT_FOUND)
|
||||
|
||||
if (resume.paidDownload) return true
|
||||
if ((user.resumeDownloadCredits || 0) > 0) return true
|
||||
return false
|
||||
async grantShareCredits(userId: string, amount = 1): Promise<boolean> {
|
||||
const result = await this.userModel.findByIdAndUpdate(
|
||||
userId,
|
||||
{ $inc: { shareCredits: amount } },
|
||||
).exec()
|
||||
return !!result
|
||||
}
|
||||
|
||||
async deductDownload(userId: string, resume: { paidDownload?: boolean; _id?: any }) {
|
||||
const user = await this.userModel.findById(userId).exec()
|
||||
if (!user) throw new HttpException('用户不存在', HttpStatus.NOT_FOUND)
|
||||
async checkAndDeductDownload(userId: string, paidDownload: boolean): Promise<boolean> {
|
||||
if (paidDownload) return true
|
||||
|
||||
if (resume.paidDownload) return
|
||||
if ((user.resumeDownloadCredits || 0) > 0) {
|
||||
user.resumeDownloadCredits = (user.resumeDownloadCredits || 0) - 1
|
||||
await user.save()
|
||||
}
|
||||
const result = await this.userModel.findOneAndUpdate(
|
||||
{ _id: userId, resumeDownloadCredits: { $gt: 0 } },
|
||||
{ $inc: { resumeDownloadCredits: -1 } },
|
||||
).exec()
|
||||
return !!result
|
||||
}
|
||||
|
||||
async grantCredits(userId: string, type: 'interview' | 'optimize' | 'download', amount: number) {
|
||||
const user = await this.userModel.findById(userId).exec()
|
||||
if (!user) throw new HttpException('用户不存在', HttpStatus.NOT_FOUND)
|
||||
if (amount <= 0) throw new HttpException('无效数量', HttpStatus.BAD_REQUEST)
|
||||
|
||||
if (type === 'interview') user.interviewCredits = (user.interviewCredits || 0) + amount
|
||||
else if (type === 'optimize') user.resumeOptimizeCredits = (user.resumeOptimizeCredits || 0) + amount
|
||||
else if (type === 'download') user.resumeDownloadCredits = (user.resumeDownloadCredits || 0) + amount
|
||||
const fieldMap: Record<string, string> = {
|
||||
interview: 'interviewCredits',
|
||||
optimize: 'resumeOptimizeCredits',
|
||||
download: 'resumeDownloadCredits',
|
||||
}
|
||||
const field = fieldMap[type]
|
||||
if (!field) throw new HttpException('无效类型', HttpStatus.BAD_REQUEST)
|
||||
|
||||
await user.save()
|
||||
const result = await this.userModel.findByIdAndUpdate(
|
||||
userId,
|
||||
{ $inc: { [field]: amount } },
|
||||
).exec()
|
||||
if (!result) throw new HttpException('用户不存在', HttpStatus.NOT_FOUND)
|
||||
}
|
||||
|
||||
async setPlanQuota(userId: string, plan: string, credits: { interview: number; resumeOptimize: number; resumeDownload: number }) {
|
||||
const user = await this.userModel.findById(userId).exec()
|
||||
if (!user) throw new HttpException('用户不存在', HttpStatus.NOT_FOUND)
|
||||
|
||||
user.remaining = 999
|
||||
user.interviewCredits = credits.interview
|
||||
user.resumeOptimizeCredits = credits.resumeOptimize
|
||||
user.resumeDownloadCredits = credits.resumeDownload
|
||||
user.freeOptimizeUsed = FREE_OPTIMIZE_LIMIT // 会员不再消耗免费次数
|
||||
|
||||
await user.save()
|
||||
async setPlanQuota(userId: string, _plan: string, credits: { interview: number; resumeOptimize: number; resumeDownload: number }) {
|
||||
const result = await this.userModel.findByIdAndUpdate(userId, {
|
||||
$set: {
|
||||
remaining: 999,
|
||||
interviewCredits: credits.interview,
|
||||
resumeOptimizeCredits: credits.resumeOptimize,
|
||||
resumeDownloadCredits: credits.resumeDownload,
|
||||
freeOptimizeUsed: FREE_OPTIMIZE_LIMIT,
|
||||
},
|
||||
}).exec()
|
||||
if (!result) throw new HttpException('用户不存在', HttpStatus.NOT_FOUND)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -48,6 +48,9 @@ export class User {
|
||||
@Prop({ default: 0 })
|
||||
freeOptimizeUsed: number // 已使用免费优化次数(上限 3)
|
||||
|
||||
@Prop({ default: 0 })
|
||||
shareCredits: number // 分享积分,每 3 次有效访问获 1 积分
|
||||
|
||||
@Prop({ default: 'user' })
|
||||
role: string // 'user' | 'admin'
|
||||
|
||||
|
||||
@@ -215,6 +215,7 @@ export class UserService {
|
||||
resumeOptimizeCredits: user.resumeOptimizeCredits ?? 0,
|
||||
resumeDownloadCredits: user.resumeDownloadCredits ?? 0,
|
||||
freeOptimizeUsed: user.freeOptimizeUsed ?? 0,
|
||||
shareCredits: user.shareCredits ?? 0,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user