初始化:职引项目 v1.0
This commit is contained in:
@@ -0,0 +1,17 @@
|
||||
# 职引后端环境变量配置
|
||||
|
||||
# MongoDB
|
||||
MONGODB_URI=mongodb://localhost:27017/zhiyin
|
||||
|
||||
# AI 主服务商 - opencode-go (deepseek-v4-flash)
|
||||
AI_PRIMARY_URL=https://opencode.ai/zen/go/v1
|
||||
AI_PRIMARY_KEY=your_primary_api_key_here
|
||||
AI_PRIMARY_MODEL=deepseek-v4-flash
|
||||
|
||||
# AI 备用服务商 - NVIDIA (stepfun-ai/step-3.5-flash)
|
||||
AI_BACKUP_URL=https://integrate.api.nvidia.com/v1
|
||||
AI_BACKUP_KEY=your_backup_api_key_here
|
||||
AI_BACKUP_MODEL=stepfun-ai/step-3.5-flash
|
||||
|
||||
# 服务端口
|
||||
PORT=3000
|
||||
@@ -0,0 +1,31 @@
|
||||
# 生产环境配置
|
||||
NODE_ENV=production
|
||||
|
||||
# 服务器配置
|
||||
PORT=3000
|
||||
HOST=0.0.0.0
|
||||
|
||||
# MongoDB 生产环境(需修改为实际生产数据库)
|
||||
MONGODB_URI=mongodb://username:password@production-host:27017/zhiyin?authSource=admin
|
||||
|
||||
# JWT 密钥(生产环境必须使用强密钥)
|
||||
JWT_SECRET=your-super-strong-jwt-secret-key-here-minimum-32-chars
|
||||
|
||||
# AI 配置(已配置)
|
||||
AI_PRIMARY_URL=https://token.sensenova.cn/v1
|
||||
AI_PRIMARY_KEY=sk-2Bbcf8pSTSl1x2BV5fKtDsUIGdfjKX7M
|
||||
AI_PRIMARY_MODEL=deepseek-v4-flash
|
||||
|
||||
AI_BACKUP_URL=https://integrate.api.nvidia.com/v1
|
||||
AI_BACKUP_KEY=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
|
||||
AI_BACKUP_MODEL=stepfun-ai/step-3.5-flash
|
||||
|
||||
# 微信小程序配置(生产环境)
|
||||
WECHAT_APPID=your-production-appid
|
||||
WECHAT_SECRET=your-production-secret
|
||||
|
||||
# 日志级别
|
||||
LOG_LEVEL=info
|
||||
|
||||
# CORS 配置(生产环境指定域名)
|
||||
ALLOWED_ORIGINS=https://yourdomain.com,https://yourdomain.com
|
||||
Binary file not shown.
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/nest-cli",
|
||||
"collection": "@nestjs/schematics",
|
||||
"sourceRoot": "src",
|
||||
"compilerOptions": {
|
||||
"deleteOutDir": true
|
||||
}
|
||||
}
|
||||
Generated
+9898
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,77 @@
|
||||
{
|
||||
"name": "zhiyin-backend",
|
||||
"version": "1.0.0",
|
||||
"description": "职引 - AI简历优化后端服务",
|
||||
"main": "dist/main.js",
|
||||
"scripts": {
|
||||
"start": "nest start",
|
||||
"start:dev": "nest start --watch",
|
||||
"start:prod": "node dist/main",
|
||||
"build": "nest build",
|
||||
"test": "jest --forceExit --detectOpenHandles",
|
||||
"test:watch": "jest --watch --forceExit",
|
||||
"test:cov": "jest --coverage --forceExit"
|
||||
},
|
||||
"jest": {
|
||||
"moduleFileExtensions": [
|
||||
"js",
|
||||
"json",
|
||||
"ts"
|
||||
],
|
||||
"rootDir": "src",
|
||||
"testRegex": ".*\\.spec\\.ts$",
|
||||
"transform": {
|
||||
"^.+\\.(t|j)s$": "ts-jest"
|
||||
},
|
||||
"collectCoverageFrom": [
|
||||
"**/*.(t|j)s"
|
||||
],
|
||||
"coverageDirectory": "../coverage",
|
||||
"testEnvironment": "node",
|
||||
"moduleNameMapper": {
|
||||
"^@/(.*)$": "<rootDir>/$1"
|
||||
}
|
||||
},
|
||||
"dependencies": {
|
||||
"@nestjs-modules/ioredis": "^2.2.1",
|
||||
"@nestjs/axios": "^3.1.3",
|
||||
"@nestjs/common": "^10.3.0",
|
||||
"@nestjs/core": "^10.3.0",
|
||||
"@nestjs/jwt": "^11.0.2",
|
||||
"@nestjs/mongoose": "^10.0.2",
|
||||
"@nestjs/passport": "^11.0.5",
|
||||
"@nestjs/platform-express": "^10.3.0",
|
||||
"@nestjs/serve-static": "^4.0.2",
|
||||
"@nestjs/throttler": "^6.5.0",
|
||||
"axios": "^1.16.1",
|
||||
"cache-manager": "^7.2.8",
|
||||
"class-transformer": "^0.5.1",
|
||||
"class-validator": "^0.14.0",
|
||||
"dotenv": "^17.4.2",
|
||||
"ioredis": "^5.11.0",
|
||||
"mammoth": "^1.12.0",
|
||||
"mongoose": "^8.0.0",
|
||||
"multer": "^2.1.1",
|
||||
"nodemailer": "^8.0.10",
|
||||
"passport": "^0.7.0",
|
||||
"passport-jwt": "^4.0.1",
|
||||
"pdf-parse": "^2.4.5",
|
||||
"reflect-metadata": "^0.2.1",
|
||||
"rxjs": "^7.8.1",
|
||||
"tesseract.js": "^7.0.0",
|
||||
"wechatpay-node-v3": "^2.2.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@nestjs/cli": "^10.3.0",
|
||||
"@nestjs/schematics": "^10.1.0",
|
||||
"@nestjs/testing": "^10.4.22",
|
||||
"@types/jest": "^30.0.0",
|
||||
"@types/node": "^20.10.0",
|
||||
"@types/nodemailer": "^8.0.0",
|
||||
"@types/supertest": "^7.2.0",
|
||||
"jest": "^30.4.2",
|
||||
"supertest": "^7.2.2",
|
||||
"ts-jest": "^29.4.9",
|
||||
"typescript": "^5.3.0"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
const mg = require('mongoose');
|
||||
const uri = 'mongodb://zhiyin:zhiyin123@192.168.136.130:27017/zhiyin?authSource=zhiyin';
|
||||
mg.connect(uri).then(async () => {
|
||||
const r = await mg.connection.db.collection('users').updateMany(
|
||||
{},
|
||||
{ $set: { role: 'admin' } }
|
||||
);
|
||||
console.log('Updated:', r.modifiedCount + r.upsertedCount);
|
||||
const users = await mg.connection.db.collection('users').find().project({ _id: 1, phone: 1, role: 1 }).toArray();
|
||||
console.log(JSON.stringify(users.map(u => ({ id: u._id.toString(), phone: u.phone, role: u.role }))));
|
||||
await mg.disconnect();
|
||||
}).catch(e => console.error(e.message));
|
||||
@@ -0,0 +1,59 @@
|
||||
import { Module } from '@nestjs/common'
|
||||
import { MongooseModule } from '@nestjs/mongoose'
|
||||
import { JwtModule } from '@nestjs/jwt'
|
||||
import { PassportModule } from '@nestjs/passport'
|
||||
import { ThrottlerModule } from '@nestjs/throttler'
|
||||
import { APP_GUARD } from '@nestjs/core'
|
||||
|
||||
import { JwtStrategy } from './common/strategies/jwt.strategy'
|
||||
import { JwtAuthGuard } from './common/guards/jwt-auth.guard'
|
||||
import { AiModule } from './modules/ai/ai.module'
|
||||
import { UserModule } from './modules/user/user.module'
|
||||
import { InterviewModule } from './modules/interview/interview.module'
|
||||
import { ResumeModule } from './modules/resume/resume.module'
|
||||
import { EmailModule } from './modules/email/email.module'
|
||||
import { PaymentModule } from './modules/payment/payment.module'
|
||||
import { MemberModule } from './modules/member/member.module'
|
||||
import { AdminModule } from './modules/admin/admin.module'
|
||||
import { UploadModule } from './modules/upload/upload.module'
|
||||
import { PositionsModule } from './modules/positions/positions.module'
|
||||
import { AnalyzeModule } from './modules/analyze/analyze.module'
|
||||
import { ProgressModule } from './modules/progress/progress.module'
|
||||
import { ContributionModule } from './modules/contribution/contribution.module'
|
||||
import { DailyQuestionModule } from './modules/daily-question/daily-question.module'
|
||||
|
||||
const MONGODB_URI = process.env.MONGODB_URI || 'mongodb://localhost:27017/zhiyin'
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
MongooseModule.forRoot(MONGODB_URI),
|
||||
PassportModule.register({ defaultStrategy: 'jwt' }),
|
||||
JwtModule.register({
|
||||
secret: process.env.JWT_SECRET || 'zhiyin-jwt-secret-2026',
|
||||
signOptions: { expiresIn: '7d' },
|
||||
}),
|
||||
ThrottlerModule.forRoot([{
|
||||
ttl: 60000,
|
||||
limit: 10,
|
||||
}]),
|
||||
UserModule,
|
||||
AiModule,
|
||||
InterviewModule,
|
||||
AnalyzeModule,
|
||||
ResumeModule,
|
||||
PositionsModule,
|
||||
UploadModule,
|
||||
AdminModule,
|
||||
MemberModule,
|
||||
EmailModule,
|
||||
PaymentModule,
|
||||
ProgressModule,
|
||||
ContributionModule,
|
||||
DailyQuestionModule,
|
||||
],
|
||||
providers: [
|
||||
JwtStrategy,
|
||||
{ provide: APP_GUARD, useClass: JwtAuthGuard },
|
||||
],
|
||||
})
|
||||
export class AppModule {}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { createParamDecorator, ExecutionContext } from '@nestjs/common'
|
||||
|
||||
export const CurrentUser = createParamDecorator(
|
||||
(data: string | undefined, ctx: ExecutionContext) => {
|
||||
const request = ctx.switchToHttp().getRequest()
|
||||
const user = request.user
|
||||
return data ? user?.[data] : user
|
||||
},
|
||||
)
|
||||
@@ -0,0 +1,3 @@
|
||||
import { SetMetadata } from '@nestjs/common'
|
||||
export const IS_PUBLIC_KEY = 'isPublic'
|
||||
export const Public = () => SetMetadata(IS_PUBLIC_KEY, true)
|
||||
@@ -0,0 +1,40 @@
|
||||
import { ExceptionFilter, Catch, ArgumentsHost, HttpException, HttpStatus, Logger } from '@nestjs/common';
|
||||
import { Request, Response } from 'express';
|
||||
|
||||
@Catch()
|
||||
export class AllExceptionsFilter implements ExceptionFilter {
|
||||
private readonly logger = new Logger(AllExceptionsFilter.name);
|
||||
|
||||
catch(exception: unknown, host: ArgumentsHost) {
|
||||
const ctx = host.switchToHttp();
|
||||
const response = ctx.getResponse<Response>();
|
||||
const request = ctx.getRequest<Request>();
|
||||
|
||||
const status = exception instanceof HttpException
|
||||
? exception.getStatus()
|
||||
: HttpStatus.INTERNAL_SERVER_ERROR;
|
||||
|
||||
const message = exception instanceof HttpException
|
||||
? exception.getResponse()
|
||||
: '服务器内部错误';
|
||||
|
||||
const errorResponse = {
|
||||
code: status,
|
||||
message: typeof message === 'string' ? message : (message as any).message || message,
|
||||
timestamp: new Date().toISOString(),
|
||||
path: request.url,
|
||||
};
|
||||
|
||||
// 记录错误日志
|
||||
if (status >= 500) {
|
||||
this.logger.error(
|
||||
`Internal error: ${request.method} ${request.url}`,
|
||||
exception instanceof Error ? exception.stack : String(exception),
|
||||
);
|
||||
} else if (status >= 400) {
|
||||
this.logger.warn(`Client error: ${request.method} ${request.url} - ${JSON.stringify(errorResponse)}`);
|
||||
}
|
||||
|
||||
response.status(status).json(errorResponse);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import { Injectable, ExecutionContext } from '@nestjs/common'
|
||||
import { AuthGuard } from '@nestjs/passport'
|
||||
import { Reflector } from '@nestjs/core'
|
||||
import { IS_PUBLIC_KEY } from '../decorators/public.decorator'
|
||||
|
||||
@Injectable()
|
||||
export class JwtAuthGuard extends AuthGuard('jwt') {
|
||||
constructor(private reflector: Reflector) {
|
||||
super()
|
||||
}
|
||||
|
||||
canActivate(context: ExecutionContext) {
|
||||
const isPublic = this.reflector.getAllAndOverride<boolean>(IS_PUBLIC_KEY, [
|
||||
context.getHandler(),
|
||||
context.getClass(),
|
||||
])
|
||||
if (isPublic) return true
|
||||
return super.canActivate(context)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import { Injectable } from '@nestjs/common'
|
||||
import { PassportStrategy } from '@nestjs/passport'
|
||||
import { ExtractJwt, Strategy } from 'passport-jwt'
|
||||
|
||||
@Injectable()
|
||||
export class JwtStrategy extends PassportStrategy(Strategy) {
|
||||
constructor() {
|
||||
super({
|
||||
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
|
||||
ignoreExpiration: false,
|
||||
secretOrKey: process.env.JWT_SECRET || 'zhiyin-jwt-secret-2026',
|
||||
})
|
||||
}
|
||||
|
||||
async validate(payload: { userId: string; phone: string }) {
|
||||
return { userId: payload.userId, phone: payload.phone }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import 'dotenv/config'
|
||||
import { NestFactory } from '@nestjs/core'
|
||||
import { ValidationPipe } from '@nestjs/common'
|
||||
import { AppModule } from './app.module'
|
||||
import { AllExceptionsFilter } from './common/filters/all-exceptions.filter'
|
||||
|
||||
async function bootstrap() {
|
||||
const app = await NestFactory.create(AppModule)
|
||||
|
||||
app.setGlobalPrefix('api')
|
||||
app.enableCors({
|
||||
origin: '*',
|
||||
methods: 'GET,HEAD,PUT,PATCH,POST,DELETE',
|
||||
credentials: true,
|
||||
})
|
||||
|
||||
app.useGlobalFilters(new AllExceptionsFilter())
|
||||
app.useGlobalPipes(new ValidationPipe({
|
||||
whitelist: true,
|
||||
forbidNonWhitelisted: true,
|
||||
transform: true,
|
||||
}))
|
||||
|
||||
const port = process.env.PORT || 3006
|
||||
await app.listen(port)
|
||||
console.log(`🚀 Server running on http://localhost:${port}`)
|
||||
}
|
||||
bootstrap()
|
||||
@@ -0,0 +1,133 @@
|
||||
import { Controller, Get, Post, Body, Query, HttpException, HttpStatus, UseGuards } from '@nestjs/common'
|
||||
import { InjectModel } from '@nestjs/mongoose'
|
||||
import { Model } from 'mongoose'
|
||||
import { Public } from '../../common/decorators/public.decorator'
|
||||
import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard'
|
||||
import { CurrentUser } from '../../common/decorators/current-user.decorator'
|
||||
import { User, UserDocument } from '../user/user.schema'
|
||||
import { Interview, InterviewDocument } from '../interview/interview.schema'
|
||||
|
||||
@Controller('admin')
|
||||
export class AdminController {
|
||||
constructor(
|
||||
@InjectModel(User.name) private userModel: Model<UserDocument>,
|
||||
@InjectModel(Interview.name) private interviewModel: Model<InterviewDocument>,
|
||||
) {}
|
||||
|
||||
@Get('check')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
async checkAdmin(@CurrentUser('userId') userId: string) {
|
||||
const user = await this.userModel.findById(userId).select('role').exec()
|
||||
return { isAdmin: user?.role === 'admin' }
|
||||
}
|
||||
|
||||
@Public()
|
||||
@Post('verify')
|
||||
async verify(@Body('adminId') adminId: string) {
|
||||
const user = await this.userModel.findById(adminId).exec()
|
||||
if (!user || user.role !== 'admin') {
|
||||
throw new HttpException('无权限访问', HttpStatus.FORBIDDEN)
|
||||
}
|
||||
return { ok: true, nickname: user.nickname || '管理员' }
|
||||
}
|
||||
|
||||
@Public()
|
||||
@Get('overview')
|
||||
async overview() {
|
||||
const [userCount, interviewCount, todayUsers, todayInterviews] = 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(),
|
||||
])
|
||||
return { userCount, interviewCount, todayUsers, todayInterviews }
|
||||
}
|
||||
|
||||
@Public()
|
||||
@Get('users')
|
||||
async getUsers(@Query('keyword') keyword: string, @Query('page') page = '1', @Query('limit') limit = '20') {
|
||||
const filter: any = {}
|
||||
if (keyword) filter.$or = [
|
||||
{ phone: { $regex: keyword, $options: 'i' } },
|
||||
{ nickname: { $regex: keyword, $options: 'i' } },
|
||||
]
|
||||
const skip = (Math.max(1, +page) - 1) * +limit
|
||||
const [users, total] = await Promise.all([
|
||||
this.userModel.find(filter).sort({ createdAt: -1 }).skip(skip).limit(+limit).select('-password').lean().exec(),
|
||||
this.userModel.countDocuments(filter).exec(),
|
||||
])
|
||||
return { users, total, page: +page }
|
||||
}
|
||||
|
||||
@Public()
|
||||
@Get('interviews')
|
||||
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.countDocuments().exec(),
|
||||
])
|
||||
return { interviews, total, page: +page }
|
||||
}
|
||||
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@Post('set-vip')
|
||||
async setVip(@Body('userId') targetUserId: string, @CurrentUser('userId') adminUserId: string) {
|
||||
const admin = await this.userModel.findById(adminUserId).exec()
|
||||
if (admin?.role !== 'admin') throw new HttpException('无权限', HttpStatus.FORBIDDEN)
|
||||
const user = await this.userModel.findById(targetUserId).exec()
|
||||
if (!user) throw new HttpException('用户不存在', HttpStatus.NOT_FOUND)
|
||||
const expireAt = new Date()
|
||||
expireAt.setDate(expireAt.getDate() + 30)
|
||||
user.plan = 'vip'
|
||||
user.vipExpireAt = expireAt
|
||||
user.remaining = 999
|
||||
await user.save()
|
||||
return { success: true, plan: 'vip', expireAt }
|
||||
}
|
||||
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@Get('admins')
|
||||
async getAdmins(@CurrentUser('userId') adminUserId: string) {
|
||||
const admin = await this.userModel.findById(adminUserId).exec()
|
||||
if (admin?.role !== 'admin') throw new HttpException('无权限', HttpStatus.FORBIDDEN)
|
||||
const admins = await this.userModel.find({ role: 'admin' }).select('phone nickname email createdAt isSystemAdmin').lean().exec()
|
||||
return { admins }
|
||||
}
|
||||
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@Post('set-admin')
|
||||
async setAdmin(@Body('userId') targetUserId: string, @CurrentUser('userId') adminUserId: string) {
|
||||
const admin = await this.userModel.findById(adminUserId).exec()
|
||||
if (admin?.role !== 'admin') throw new HttpException('无权限', HttpStatus.FORBIDDEN)
|
||||
const user = await this.userModel.findById(targetUserId).exec()
|
||||
if (!user) throw new HttpException('用户不存在', HttpStatus.NOT_FOUND)
|
||||
if (user.role === 'admin') throw new HttpException('该用户已是管理员', HttpStatus.BAD_REQUEST)
|
||||
user.role = 'admin'
|
||||
await user.save()
|
||||
return { success: true, message: '已设为管理员' }
|
||||
}
|
||||
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@Get('config')
|
||||
async getConfig(@CurrentUser('userId') adminUserId: string) {
|
||||
const admin = await this.userModel.findById(adminUserId).exec()
|
||||
if (admin?.role !== 'admin') throw new HttpException('无权限', HttpStatus.FORBIDDEN)
|
||||
return {
|
||||
interview: {
|
||||
maxRoundsFree: 5,
|
||||
maxRoundsVip: 10,
|
||||
dailyFreeLimit: 3,
|
||||
},
|
||||
diagnosis: {
|
||||
dailyFreeLimit: 2,
|
||||
},
|
||||
optimize: {
|
||||
dailyFreeLimit: 2,
|
||||
},
|
||||
price: {
|
||||
monthly: 2900, // 分
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import { Module } from '@nestjs/common'
|
||||
import { MongooseModule } from '@nestjs/mongoose'
|
||||
import { AdminController } from './admin.controller'
|
||||
import { User, UserSchema } from '../user/user.schema'
|
||||
import { Interview, InterviewSchema } from '../interview/interview.schema'
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
MongooseModule.forFeature([{ name: User.name, schema: UserSchema }]),
|
||||
MongooseModule.forFeature([{ name: Interview.name, schema: InterviewSchema }]),
|
||||
],
|
||||
controllers: [AdminController],
|
||||
})
|
||||
export class AdminModule {}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { Global, Module } from '@nestjs/common'
|
||||
import { AiService } from './ai.service'
|
||||
|
||||
@Global()
|
||||
@Module({
|
||||
providers: [AiService],
|
||||
exports: [AiService],
|
||||
})
|
||||
export class AiModule {}
|
||||
@@ -0,0 +1,72 @@
|
||||
import { Injectable, Logger } from '@nestjs/common'
|
||||
import axios from 'axios'
|
||||
|
||||
interface AiCallOptions {
|
||||
systemPrompt: string
|
||||
userMessage: string
|
||||
temperature?: number
|
||||
maxTokens?: number
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class AiService {
|
||||
private readonly logger = new Logger(AiService.name)
|
||||
|
||||
private readonly primaryUrl = process.env.AI_PRIMARY_URL || 'https://token.sensenova.cn/v1'
|
||||
private readonly primaryKey = process.env.AI_PRIMARY_KEY || ''
|
||||
private readonly primaryModel = process.env.AI_PRIMARY_MODEL || 'deepseek-v4-flash'
|
||||
|
||||
private readonly backupUrl = process.env.AI_BACKUP_URL || 'https://integrate.api.nvidia.com/v1'
|
||||
private readonly backupKey = process.env.AI_BACKUP_KEY || ''
|
||||
private readonly backupModel = process.env.AI_BACKUP_MODEL || 'stepfun-ai/step-3.5-flash'
|
||||
|
||||
async call(options: AiCallOptions): Promise<string> {
|
||||
const { systemPrompt, userMessage, temperature = 0.7, maxTokens = 2048 } = options
|
||||
|
||||
// Try primary AI
|
||||
try {
|
||||
const result = await this.callApi(this.primaryUrl, this.primaryKey, this.primaryModel, systemPrompt, userMessage, temperature, maxTokens)
|
||||
if (result) return result
|
||||
} catch (e) {
|
||||
this.logger.warn(`Primary AI failed: ${(e as Error).message}, trying backup...`)
|
||||
}
|
||||
|
||||
// Try backup AI
|
||||
try {
|
||||
const result = await this.callApi(this.backupUrl, this.backupKey, this.backupModel, systemPrompt, userMessage, temperature, maxTokens)
|
||||
if (result) return result
|
||||
} catch (e) {
|
||||
this.logger.warn(`Backup AI also failed: ${(e as Error).message}`)
|
||||
}
|
||||
|
||||
// Final fallback
|
||||
throw new Error('AI 服务暂时不可用,请稍后重试')
|
||||
}
|
||||
|
||||
private async callApi(
|
||||
baseUrl: string, apiKey: string, model: string,
|
||||
systemPrompt: string, userMessage: string,
|
||||
temperature: number, maxTokens: number,
|
||||
): Promise<string | null> {
|
||||
const res = await axios.post(
|
||||
`${baseUrl}/chat/completions`,
|
||||
{
|
||||
model,
|
||||
messages: [
|
||||
{ role: 'system', content: systemPrompt },
|
||||
{ role: 'user', content: userMessage },
|
||||
],
|
||||
temperature,
|
||||
max_tokens: maxTokens,
|
||||
},
|
||||
{
|
||||
headers: {
|
||||
'Authorization': `Bearer ${apiKey}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
timeout: 60000,
|
||||
},
|
||||
)
|
||||
return res.data?.choices?.[0]?.message?.content || null
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import { Controller, Post, Body, HttpException, HttpStatus, UseGuards } from '@nestjs/common'
|
||||
import { AnalyzeService } from './analyze.service'
|
||||
import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard'
|
||||
import { CurrentUser } from '../../common/decorators/current-user.decorator'
|
||||
import { UserService } from '../user/user.service'
|
||||
|
||||
@Controller('analyze')
|
||||
export class AnalyzeController {
|
||||
constructor(
|
||||
private analyzeService: AnalyzeService,
|
||||
private userService: UserService,
|
||||
) {}
|
||||
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@Post('diagnosis')
|
||||
async diagnosis(@Body('content') content: string, @CurrentUser('userId') userId: string) {
|
||||
await this.checkAnalyzeLimit(userId)
|
||||
return this.analyzeService.diagnose(content)
|
||||
}
|
||||
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@Post('optimize')
|
||||
async optimize(@Body('content') content: string, @Body('direction') direction: string, @CurrentUser('userId') userId: string) {
|
||||
await this.checkAnalyzeLimit(userId)
|
||||
return this.analyzeService.optimize(content, direction)
|
||||
}
|
||||
|
||||
private async checkAnalyzeLimit(userId: string) {
|
||||
const user = await this.userService.getModel().findById(userId).exec()
|
||||
if (!user) throw new HttpException('用户不存在', HttpStatus.NOT_FOUND)
|
||||
if (user.plan === 'vip') return // VIP 不限次
|
||||
if (user.remaining <= 0) {
|
||||
throw new HttpException('免费版每日次数已用完,升级会员后不限次使用', HttpStatus.FORBIDDEN)
|
||||
}
|
||||
// 扣减一次
|
||||
user.remaining -= 1
|
||||
await user.save()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import { Module } from '@nestjs/common'
|
||||
import { AnalyzeController } from './analyze.controller'
|
||||
import { AnalyzeService } from './analyze.service'
|
||||
import { UserModule } from '../user/user.module'
|
||||
|
||||
@Module({
|
||||
imports: [UserModule],
|
||||
controllers: [AnalyzeController],
|
||||
providers: [AnalyzeService],
|
||||
})
|
||||
export class AnalyzeModule {}
|
||||
@@ -0,0 +1,57 @@
|
||||
import { Injectable } from '@nestjs/common'
|
||||
import { AiService } from '../ai/ai.service'
|
||||
|
||||
@Injectable()
|
||||
export class AnalyzeService {
|
||||
constructor(private aiService: AiService) {}
|
||||
|
||||
async diagnose(content: string) {
|
||||
const result = await this.aiService.call({
|
||||
systemPrompt: `你是一位专业的简历评估专家。分析以下简历内容,给出评估。必须使用以下JSON格式输出,不要多余内容:
|
||||
{
|
||||
"score": 0-100,
|
||||
"issues": [
|
||||
{ "level": "high|medium|low", "title": "问题标题", "desc": "问题描述" }
|
||||
],
|
||||
"suggestions": ["建议1", "建议2", "建议3", "建议4"]
|
||||
}`,
|
||||
userMessage: `请分析以下简历:\n\n${content}`,
|
||||
temperature: 0.5,
|
||||
maxTokens: 2048,
|
||||
})
|
||||
|
||||
try {
|
||||
return JSON.parse(result)
|
||||
} catch {
|
||||
return {
|
||||
score: 60,
|
||||
issues: [{ level: 'medium', title: 'AI 分析异常', desc: '无法解析完整结果,建议重试' }],
|
||||
suggestions: ['请重新提交简历进行诊断'],
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async optimize(content: string, direction: string) {
|
||||
const result = await this.aiService.call({
|
||||
systemPrompt: `你是一位资深的简历优化专家。按照用户指定的优化方向,优化以下简历内容。
|
||||
优化方向: ${direction}
|
||||
输出格式:
|
||||
{
|
||||
"optimized": "优化后的简历全文",
|
||||
"changes": ["改动1", "改动2", ...]
|
||||
}`,
|
||||
userMessage: `原始简历:\n\n${content}\n\n优化方向:${direction}`,
|
||||
temperature: 0.6,
|
||||
maxTokens: 3072,
|
||||
})
|
||||
|
||||
try {
|
||||
return JSON.parse(result)
|
||||
} catch {
|
||||
return {
|
||||
optimized: content,
|
||||
changes: ['AI 优化异常,返回原始内容'],
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
import { Controller, Post, Get, Body, Param, UseGuards } from '@nestjs/common'
|
||||
import { InjectModel } from '@nestjs/mongoose'
|
||||
import { Model } from 'mongoose'
|
||||
import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard'
|
||||
import { CurrentUser } from '../../common/decorators/current-user.decorator'
|
||||
import { Contribution, ContributionDocument } from '../schemas/contribution.schema'
|
||||
import { CompanyBank, CompanyBankDocument } from '../schemas/company-bank.schema'
|
||||
|
||||
@Controller('contribution')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
export class ContributionController {
|
||||
constructor(
|
||||
@InjectModel(Contribution.name) private contributionModel: Model<ContributionDocument>,
|
||||
@InjectModel(CompanyBank.name) private companyBankModel: Model<CompanyBankDocument>,
|
||||
) {}
|
||||
|
||||
@Post()
|
||||
async create(
|
||||
@CurrentUser('userId') userId: string,
|
||||
@Body() body: {
|
||||
interviewId: string
|
||||
company: string
|
||||
position: string
|
||||
rounds?: string
|
||||
questions?: string[]
|
||||
experience?: string
|
||||
tags?: string[]
|
||||
},
|
||||
) {
|
||||
const contribution = await this.contributionModel.create({
|
||||
userId,
|
||||
interviewId: body.interviewId,
|
||||
company: body.company,
|
||||
position: body.position,
|
||||
rounds: body.rounds || '',
|
||||
questions: body.questions || [],
|
||||
experience: body.experience || '',
|
||||
tags: body.tags || [],
|
||||
verified: false,
|
||||
})
|
||||
|
||||
// Update company bank
|
||||
if (body.questions && body.questions.length > 0) {
|
||||
let bank = await this.companyBankModel.findOne({
|
||||
company: body.company,
|
||||
position: body.position,
|
||||
}).exec()
|
||||
|
||||
if (!bank) {
|
||||
bank = await this.companyBankModel.create({
|
||||
company: body.company,
|
||||
position: body.position,
|
||||
questions: [],
|
||||
contributionCount: 0,
|
||||
viewCount: 0,
|
||||
})
|
||||
}
|
||||
|
||||
// Add new questions (avoid duplicates)
|
||||
for (const q of body.questions) {
|
||||
const exists = bank.questions.some(eq => eq.content === q)
|
||||
if (!exists) {
|
||||
bank.questions.push({
|
||||
content: q,
|
||||
type: 'general',
|
||||
referenceAnswer: '',
|
||||
difficulty: 'medium',
|
||||
frequency: 1,
|
||||
tags: body.tags || [],
|
||||
})
|
||||
} else {
|
||||
// Increment frequency
|
||||
const existing = bank.questions.find(eq => eq.content === q)
|
||||
if (existing) existing.frequency += 1
|
||||
}
|
||||
}
|
||||
bank.contributionCount += 1
|
||||
await bank.save()
|
||||
}
|
||||
|
||||
return {
|
||||
id: (contribution as any)._id.toString(),
|
||||
company: contribution.company,
|
||||
position: contribution.position,
|
||||
message: '感谢你的分享!你的面经将帮助更多同学准备面试',
|
||||
}
|
||||
}
|
||||
|
||||
@Get('company/:company/position/:position')
|
||||
async getBank(@Param('company') company: string, @Param('position') position: string) {
|
||||
const bank = await this.companyBankModel.findOne({ company, position }).exec()
|
||||
if (!bank) return { company, position, questions: [], contributionCount: 0 }
|
||||
|
||||
bank.viewCount += 1
|
||||
await bank.save()
|
||||
|
||||
return {
|
||||
company: bank.company,
|
||||
position: bank.position,
|
||||
questions: bank.questions.sort((a, b) => b.frequency - a.frequency),
|
||||
contributionCount: bank.contributionCount,
|
||||
viewCount: bank.viewCount,
|
||||
}
|
||||
}
|
||||
|
||||
@Get('company/:company')
|
||||
async getCompanyBanks(@Param('company') company: string) {
|
||||
const banks = await this.companyBankModel.find({ company }).exec()
|
||||
return banks.map(b => ({
|
||||
position: b.position,
|
||||
questionCount: b.questions.length,
|
||||
contributionCount: b.contributionCount,
|
||||
}))
|
||||
}
|
||||
|
||||
@Get('my')
|
||||
async getMyContributions(@CurrentUser('userId') userId: string) {
|
||||
return this.contributionModel
|
||||
.find({ userId })
|
||||
.sort({ createdAt: -1 })
|
||||
.select('company position rounds experience createdAt')
|
||||
.exec()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import { Module } from '@nestjs/common'
|
||||
import { MongooseModule } from '@nestjs/mongoose'
|
||||
import { ContributionController } from './contribution.controller'
|
||||
import { Contribution, ContributionSchema } from '../schemas/contribution.schema'
|
||||
import { CompanyBank, CompanyBankSchema } from '../schemas/company-bank.schema'
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
MongooseModule.forFeature([
|
||||
{ name: Contribution.name, schema: ContributionSchema },
|
||||
{ name: CompanyBank.name, schema: CompanyBankSchema },
|
||||
]),
|
||||
],
|
||||
controllers: [ContributionController],
|
||||
})
|
||||
export class ContributionModule {}
|
||||
@@ -0,0 +1,63 @@
|
||||
import { Controller, Get, Param, Query, UseGuards } from '@nestjs/common'
|
||||
import { InjectModel } from '@nestjs/mongoose'
|
||||
import { Model } from 'mongoose'
|
||||
import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard'
|
||||
import { DailyQuestion, DailyQuestionDocument } from '../schemas/daily-question.schema'
|
||||
|
||||
@Controller('daily-question')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
export class DailyQuestionController {
|
||||
constructor(
|
||||
@InjectModel(DailyQuestion.name) private dailyQuestionModel: Model<DailyQuestionDocument>,
|
||||
) {}
|
||||
|
||||
@Get()
|
||||
async getToday(@Query('position') position?: string) {
|
||||
const filter: any = {}
|
||||
if (position) filter.position = position
|
||||
|
||||
const question = await this.dailyQuestionModel
|
||||
.findOne(filter)
|
||||
.sort({ date: -1 })
|
||||
.exec()
|
||||
|
||||
if (!question) {
|
||||
// Return a default question if no specific one found
|
||||
const defaultQ = await this.dailyQuestionModel.findOne().sort({ date: -1 }).exec()
|
||||
if (defaultQ) return defaultQ
|
||||
|
||||
return {
|
||||
position: '通用',
|
||||
question: '请做一个简单的自我介绍,突出你的核心优势和职业目标。',
|
||||
referenceAnswer: '建议结构:1) 基本信息 2) 教育背景与专业 3) 实习/项目经历中的亮点 4) 为什么选择这个岗位 5) 职业目标。控制在1-2分钟内。',
|
||||
category: 'behavioral',
|
||||
}
|
||||
}
|
||||
|
||||
return question
|
||||
}
|
||||
|
||||
@Get('position/:position')
|
||||
async getByPosition(@Param('position') position: string) {
|
||||
const questions = await this.dailyQuestionModel
|
||||
.find({ position })
|
||||
.sort({ date: -1 })
|
||||
.limit(10)
|
||||
.exec()
|
||||
|
||||
return questions.length > 0 ? questions : [
|
||||
{
|
||||
position,
|
||||
question: `作为${position}岗位的候选人,请分享一个你最有成就感的项目经历。`,
|
||||
referenceAnswer: '使用STAR法则:Situation(背景)、Task(任务)、Action(行动)、Result(结果),重点突出你在项目中的角色和贡献。',
|
||||
category: 'project',
|
||||
},
|
||||
{
|
||||
position,
|
||||
question: `在${position}岗位上,你认为自己最大的优势是什么?最大的不足是什么?`,
|
||||
referenceAnswer: '优势要结合岗位要求,用具体例子支撑;不足要真实但可改进,说明你已经在如何提升。',
|
||||
category: 'behavioral',
|
||||
},
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import { Module } from '@nestjs/common'
|
||||
import { MongooseModule } from '@nestjs/mongoose'
|
||||
import { DailyQuestionController } from './daily-question.controller'
|
||||
import { DailyQuestion, DailyQuestionSchema } from '../schemas/daily-question.schema'
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
MongooseModule.forFeature([
|
||||
{ name: DailyQuestion.name, schema: DailyQuestionSchema },
|
||||
]),
|
||||
],
|
||||
controllers: [DailyQuestionController],
|
||||
})
|
||||
export class DailyQuestionModule {}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { Module, Global } from '@nestjs/common'
|
||||
import { EmailService } from './email.service'
|
||||
|
||||
@Global()
|
||||
@Module({
|
||||
providers: [EmailService],
|
||||
exports: [EmailService],
|
||||
})
|
||||
export class EmailModule {}
|
||||
@@ -0,0 +1,63 @@
|
||||
import * as nodemailer from 'nodemailer'
|
||||
import { Injectable, Logger } from '@nestjs/common'
|
||||
|
||||
@Injectable()
|
||||
export class EmailService {
|
||||
private readonly logger = new Logger(EmailService.name)
|
||||
private transporter: nodemailer.Transporter | null = null
|
||||
|
||||
constructor() {
|
||||
this.initTransporter()
|
||||
}
|
||||
|
||||
private initTransporter() {
|
||||
try {
|
||||
this.transporter = nodemailer.createTransport({
|
||||
host: process.env.EMAIL_HOST || 'smtp.qiye.aliyun.com',
|
||||
port: Number(process.env.EMAIL_PORT) || 465,
|
||||
secure: process.env.EMAIL_SECURE === 'true',
|
||||
auth: {
|
||||
user: process.env.EMAIL_USER || 'contact@yuzhiran.com',
|
||||
pass: process.env.EMAIL_PASSWORD,
|
||||
},
|
||||
})
|
||||
this.logger.log('邮件服务初始化完成')
|
||||
} catch (e) {
|
||||
this.logger.error('邮件服务初始化失败', e)
|
||||
}
|
||||
}
|
||||
|
||||
async sendVerificationCode(to: string, code: string): Promise<boolean> {
|
||||
if (!this.transporter) {
|
||||
this.logger.warn('邮件服务未初始化,跳过发送')
|
||||
return false
|
||||
}
|
||||
try {
|
||||
const fromName = process.env.EMAIL_FROM || 'AI磁场 <contact@yuzhiran.com>'
|
||||
await this.transporter.sendMail({
|
||||
from: fromName,
|
||||
to,
|
||||
subject: 'AI磁场 - 登录验证码',
|
||||
html: `
|
||||
<div style="font-family: -apple-system, sans-serif; max-width: 480px; margin: 0 auto; padding: 32px;">
|
||||
<div style="text-align: center; margin-bottom: 24px;">
|
||||
<h1 style="font-size: 28px; color: #4F46E5; margin: 0;">AI 磁场</h1>
|
||||
<p style="color: #9CA3AF; font-size: 14px; margin: 4px 0 0;">您的登录验证码</p>
|
||||
</div>
|
||||
<div style="background: #F3F4F6; border-radius: 16px; padding: 32px; text-align: center;">
|
||||
<p style="font-size: 14px; color: #6B7280; margin: 0 0 16px;">请输入以下验证码完成登录</p>
|
||||
<div style="font-size: 40px; font-weight: 800; color: #4F46E5; letter-spacing: 8px; margin: 16px 0;">${code}</div>
|
||||
<p style="font-size: 12px; color: #9CA3AF; margin: 16px 0 0;">验证码 10 分钟内有效,请勿泄露给他人</p>
|
||||
</div>
|
||||
<p style="font-size: 12px; color: #D1D5DB; text-align: center; margin-top: 24px;">宇之然AI磁场 · AI 助力你的求职之路</p>
|
||||
</div>
|
||||
`,
|
||||
})
|
||||
this.logger.log(`验证码已发送至 ${to}`)
|
||||
return true
|
||||
} catch (e) {
|
||||
this.logger.error(`发送邮件失败: ${to}`, e)
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import { Controller, Post, Get, Param, Body } from '@nestjs/common'
|
||||
import { InterviewService } from './interview.service'
|
||||
import { CurrentUser } from '../../common/decorators/current-user.decorator'
|
||||
|
||||
@Controller('interview')
|
||||
export class InterviewController {
|
||||
constructor(private interviewService: InterviewService) {}
|
||||
|
||||
// 静态路由必须放在 :id 动态路由之前
|
||||
|
||||
@Get('list/all')
|
||||
async getList(@CurrentUser('userId') userId: string) {
|
||||
return this.interviewService.getList(userId)
|
||||
}
|
||||
|
||||
@Get('stats/mine')
|
||||
async getStats(@CurrentUser('userId') userId: string) {
|
||||
return this.interviewService.getStats(userId)
|
||||
}
|
||||
|
||||
@Post('create')
|
||||
async create(@CurrentUser('userId') userId: string, @Body('position') position: string) {
|
||||
return this.interviewService.create(userId, position)
|
||||
}
|
||||
|
||||
@Post(':id/answer')
|
||||
async answer(
|
||||
@Param('id') id: string,
|
||||
@CurrentUser('userId') userId: string,
|
||||
@Body('answer') answer: string,
|
||||
) {
|
||||
return this.interviewService.answer(id, userId, answer)
|
||||
}
|
||||
|
||||
@Post(':id/complete')
|
||||
async complete(@Param('id') id: string, @CurrentUser('userId') userId: string) {
|
||||
return this.interviewService.complete(id, userId)
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
async getDetail(@Param('id') id: string, @CurrentUser('userId') userId: string) {
|
||||
return this.interviewService.getDetail(id, userId)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import { Module } from '@nestjs/common'
|
||||
import { MongooseModule } from '@nestjs/mongoose'
|
||||
import { InterviewController } from './interview.controller'
|
||||
import { InterviewService } from './interview.service'
|
||||
import { Interview, InterviewSchema } from './interview.schema'
|
||||
import { Progress, ProgressSchema } from '../schemas/progress.schema'
|
||||
import { UserModule } from '../user/user.module'
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
MongooseModule.forFeature([
|
||||
{ name: Interview.name, schema: InterviewSchema },
|
||||
{ name: Progress.name, schema: ProgressSchema },
|
||||
]),
|
||||
UserModule,
|
||||
],
|
||||
controllers: [InterviewController],
|
||||
providers: [InterviewService],
|
||||
})
|
||||
export class InterviewModule {}
|
||||
@@ -0,0 +1,31 @@
|
||||
import { Prop, Schema, SchemaFactory } from '@nestjs/mongoose'
|
||||
import { Document, Types } from 'mongoose'
|
||||
|
||||
export type InterviewDocument = Interview & Document
|
||||
|
||||
@Schema({ timestamps: true })
|
||||
export class Interview {
|
||||
@Prop({ type: Types.ObjectId, ref: 'User', required: true })
|
||||
userId: Types.ObjectId
|
||||
|
||||
@Prop({ required: true })
|
||||
position: string
|
||||
|
||||
@Prop({ default: 'in_progress' }) // in_progress | completed
|
||||
status: string
|
||||
|
||||
@Prop({ default: 0 })
|
||||
totalScore: number
|
||||
|
||||
@Prop({ default: 0 })
|
||||
questionCount: number
|
||||
|
||||
@Prop({ type: [{ role: String, content: String, score: Number }], default: [] })
|
||||
messages: { role: string; content: string; score?: number }[]
|
||||
|
||||
@Prop({ default: '' })
|
||||
summary: string
|
||||
}
|
||||
|
||||
export const InterviewSchema = SchemaFactory.createForClass(Interview)
|
||||
InterviewSchema.index({ userId: 1, createdAt: -1 })
|
||||
@@ -0,0 +1,264 @@
|
||||
import { Injectable, HttpException, HttpStatus, forwardRef, Inject } from '@nestjs/common'
|
||||
import { InjectModel } from '@nestjs/mongoose'
|
||||
import { Model } from 'mongoose'
|
||||
import { Interview, InterviewDocument } from './interview.schema'
|
||||
import { Progress, ProgressDocument } from '../schemas/progress.schema'
|
||||
import { AiService } from '../ai/ai.service'
|
||||
import { UserService } from '../user/user.service'
|
||||
|
||||
@Injectable()
|
||||
export class InterviewService {
|
||||
constructor(
|
||||
@InjectModel(Interview.name) private interviewModel: Model<InterviewDocument>,
|
||||
@InjectModel(Progress.name) private progressModel: Model<ProgressDocument>,
|
||||
private aiService: AiService,
|
||||
private userService: UserService,
|
||||
) {}
|
||||
|
||||
async create(userId: string, position: string) {
|
||||
// 扣减使用次数
|
||||
await this.userService.deductRemaining(userId)
|
||||
|
||||
const firstQuestion = await this.aiService.call({
|
||||
systemPrompt: `你是一位专业的${position}面试官。请针对校招该岗位提出第一个面试问题,要求具体且有针对性。直接输出问题,不要多余内容。`,
|
||||
userMessage: `请为${position}岗位的校招候选人提出第一个面试问题。`,
|
||||
temperature: 0.8,
|
||||
})
|
||||
|
||||
const interview = await this.interviewModel.create({
|
||||
userId,
|
||||
position,
|
||||
messages: [{ role: 'ai', content: firstQuestion }],
|
||||
questionCount: 1,
|
||||
})
|
||||
|
||||
return {
|
||||
id: interview._id.toString(),
|
||||
position: interview.position,
|
||||
messages: interview.messages,
|
||||
questionCount: interview.questionCount,
|
||||
}
|
||||
}
|
||||
|
||||
async answer(interviewId: string, userId: string, answer: string) {
|
||||
const interview = await this.interviewModel.findOne({ _id: interviewId, userId }).exec()
|
||||
if (!interview) throw new HttpException('面试不存在', HttpStatus.NOT_FOUND)
|
||||
if (interview.status === 'completed') throw new HttpException('面试已结束', HttpStatus.BAD_REQUEST)
|
||||
|
||||
// 检查轮次限制
|
||||
const user = await this.userService.getModel().findById(userId).exec()
|
||||
const maxRounds = user?.plan === 'vip' ? 10 : 5
|
||||
if (interview.questionCount >= maxRounds) {
|
||||
throw new HttpException(
|
||||
user?.plan === 'vip' ? '已达到每场面试最大轮次(10轮)' : '免费版每场最多5轮,升级会员可享10轮',
|
||||
HttpStatus.FORBIDDEN
|
||||
)
|
||||
}
|
||||
|
||||
// Save user's answer
|
||||
interview.messages.push({ role: 'user', content: answer })
|
||||
interview.questionCount += 1
|
||||
|
||||
// AI evaluates answer and generates next question
|
||||
const conversationHistory = interview.messages
|
||||
.slice(-6)
|
||||
.map(m => `${m.role === 'ai' ? '面试官' : '候选人'}: ${m.content}`)
|
||||
.join('\n')
|
||||
|
||||
const aiResponse = await this.aiService.call({
|
||||
systemPrompt: `你是一位专业的面试官。评估候选人的回答,然后提出下一个问题。
|
||||
- 如果这已经是第5-8个问题,给出总结性评价并建议结束面试
|
||||
- 评估要简短,然后立即问下一个问题
|
||||
- 使用「回答评价:...新的问题:...」的格式。`,
|
||||
userMessage: `岗位: ${interview.position}
|
||||
对话历史:
|
||||
${conversationHistory}
|
||||
|
||||
候选人的回答: ${answer}
|
||||
|
||||
请评估并问下一个问题。`,
|
||||
temperature: 0.7,
|
||||
maxTokens: 1024,
|
||||
})
|
||||
|
||||
interview.messages.push({ role: 'ai', content: aiResponse })
|
||||
await interview.save()
|
||||
|
||||
return {
|
||||
id: interview._id.toString(),
|
||||
messages: interview.messages.slice(-2),
|
||||
questionCount: interview.questionCount,
|
||||
}
|
||||
}
|
||||
|
||||
async complete(interviewId: string, userId: string) {
|
||||
const interview = await this.interviewModel.findOne({ _id: interviewId, userId }).exec()
|
||||
if (!interview) throw new HttpException('面试不存在', HttpStatus.NOT_FOUND)
|
||||
if (interview.status === 'completed') throw new HttpException('面试已结束', HttpStatus.BAD_REQUEST)
|
||||
|
||||
// Generate final summary with dimension scores
|
||||
const fullConversation = interview.messages
|
||||
.map(m => `${m.role === 'ai' ? '面试官' : '候选人'}: ${m.content}`)
|
||||
.join('\n')
|
||||
|
||||
const summary = await this.aiService.call({
|
||||
systemPrompt: `你是一位专业的面试评估师。根据面试对话,生成评估报告。输出JSON格式:
|
||||
{
|
||||
"总体评分": 85,
|
||||
"逻辑思维": 80,
|
||||
"表达能力": 85,
|
||||
"专业度": 90,
|
||||
"稳定性": 82,
|
||||
"优点": ["逻辑清晰", "举例充分"],
|
||||
"不足": ["回答过长", "缺少数据支撑"],
|
||||
"建议": ["使用STAR法则", "控制回答时间"]
|
||||
}`,
|
||||
userMessage: `岗位: ${interview.position}
|
||||
|
||||
面试记录:
|
||||
${fullConversation}
|
||||
|
||||
请生成评估报告。`,
|
||||
temperature: 0.5,
|
||||
maxTokens: 2048,
|
||||
})
|
||||
|
||||
// Parse summary
|
||||
let totalScore = 0
|
||||
let dimensions = { logic: 0, expression: 0, professionalism: 0, stability: 0 }
|
||||
try {
|
||||
const parsed = JSON.parse(summary)
|
||||
totalScore = parsed.总体评分 || parsed.score || 0
|
||||
dimensions = {
|
||||
logic: parsed.逻辑思维 || 0,
|
||||
expression: parsed.表达能力 || 0,
|
||||
professionalism: parsed.专业度 || 0,
|
||||
stability: parsed.稳定性 || 0,
|
||||
}
|
||||
} catch {
|
||||
const match = summary.match(/(\d{1,3})(?=\s*分)/)
|
||||
totalScore = match ? parseInt(match[1]) : 0
|
||||
}
|
||||
|
||||
interview.status = 'completed'
|
||||
interview.totalScore = totalScore
|
||||
interview.summary = summary
|
||||
await interview.save()
|
||||
|
||||
// === PROGRESS TRACKING ===
|
||||
await this.trackProgress(userId, interview._id.toString(), interview.position, totalScore, dimensions)
|
||||
|
||||
return {
|
||||
id: interview._id.toString(),
|
||||
totalScore,
|
||||
summary,
|
||||
dimensions,
|
||||
questionCount: interview.questionCount,
|
||||
position: interview.position,
|
||||
contributionPrompt: '分享你的面试经验,帮助更多同学!是否愿意贡献面经?(选填公司名称 + 遇到的面试题)',
|
||||
}
|
||||
}
|
||||
|
||||
private async trackProgress(
|
||||
userId: string,
|
||||
interviewId: string,
|
||||
position: string,
|
||||
totalScore: number,
|
||||
dimensions: { logic: number; expression: number; professionalism: number; stability: number },
|
||||
) {
|
||||
let progress = await this.progressModel.findOne({ userId }).exec()
|
||||
if (!progress) {
|
||||
progress = await this.progressModel.create({
|
||||
userId,
|
||||
totalInterviews: 0,
|
||||
completedInterviews: 0,
|
||||
recentScores: [],
|
||||
streakHistory: [],
|
||||
})
|
||||
}
|
||||
|
||||
progress.totalInterviews += 1
|
||||
progress.completedInterviews += 1
|
||||
|
||||
// Update rolling averages
|
||||
const n = progress.completedInterviews
|
||||
progress.avgLogic = Math.round(((progress.avgLogic * (n - 1)) + dimensions.logic) / n)
|
||||
progress.avgExpression = Math.round(((progress.avgExpression * (n - 1)) + dimensions.expression) / n)
|
||||
progress.avgProfessionalism = Math.round(((progress.avgProfessionalism * (n - 1)) + dimensions.professionalism) / n)
|
||||
progress.avgStability = Math.round(((progress.avgStability * (n - 1)) + dimensions.stability) / n)
|
||||
|
||||
// Add to recent scores
|
||||
progress.recentScores.push({
|
||||
interviewId,
|
||||
date: new Date(),
|
||||
position,
|
||||
totalScore,
|
||||
dimensions,
|
||||
})
|
||||
// Keep only last 20
|
||||
if (progress.recentScores.length > 20) {
|
||||
progress.recentScores = progress.recentScores.slice(-20)
|
||||
}
|
||||
|
||||
// === STREAK TRACKING ===
|
||||
const today = new Date()
|
||||
today.setHours(0, 0, 0, 0)
|
||||
|
||||
if (progress.lastInterviewDate) {
|
||||
const lastDate = new Date(progress.lastInterviewDate)
|
||||
lastDate.setHours(0, 0, 0, 0)
|
||||
const diffDays = Math.floor((today.getTime() - lastDate.getTime()) / (1000 * 60 * 60 * 24))
|
||||
|
||||
if (diffDays === 1) {
|
||||
// Consecutive day
|
||||
progress.streak += 1
|
||||
} else if (diffDays > 1) {
|
||||
// Streak broken
|
||||
progress.streak = 1
|
||||
}
|
||||
// same day = no change
|
||||
} else {
|
||||
progress.streak = 1
|
||||
}
|
||||
|
||||
progress.lastInterviewDate = today
|
||||
progress.streakHistory.push(today)
|
||||
|
||||
await progress.save()
|
||||
}
|
||||
|
||||
async getDetail(interviewId: string, userId: string) {
|
||||
const interview = await this.interviewModel.findOne({ _id: interviewId, userId }).exec()
|
||||
if (!interview) throw new HttpException('面试不存在', HttpStatus.NOT_FOUND)
|
||||
return interview
|
||||
}
|
||||
|
||||
async getList(userId: string) {
|
||||
const interviews = await this.interviewModel
|
||||
.find({ userId })
|
||||
.sort({ createdAt: -1 })
|
||||
.select('position status totalScore questionCount createdAt')
|
||||
.exec()
|
||||
|
||||
return interviews.map(i => ({
|
||||
id: i._id.toString(),
|
||||
position: i.position,
|
||||
status: i.status,
|
||||
totalScore: i.totalScore,
|
||||
questionCount: i.questionCount,
|
||||
time: (i as any).createdAt,
|
||||
}))
|
||||
}
|
||||
|
||||
async getStats(userId: string) {
|
||||
const interviews = await this.interviewModel.find({ userId }).exec()
|
||||
const completed = interviews.filter(i => i.status === 'completed')
|
||||
const totalScore = completed.reduce((s, i) => s + i.totalScore, 0)
|
||||
|
||||
return {
|
||||
interviewCount: interviews.length,
|
||||
completedCount: completed.length,
|
||||
avgScore: completed.length > 0 ? Math.round(totalScore / completed.length) : 0,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
import { Controller, Post, Get, Body, HttpException, HttpStatus, UseGuards } from '@nestjs/common'
|
||||
import { InjectModel } from '@nestjs/mongoose'
|
||||
import { Model } from 'mongoose'
|
||||
import { User, UserDocument } from '../user/user.schema'
|
||||
import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard'
|
||||
import { CurrentUser } from '../../common/decorators/current-user.decorator'
|
||||
import { Public } from '../../common/decorators/public.decorator'
|
||||
|
||||
const GROWTH_PRICE = 1990
|
||||
const DURATION_DAYS = 30
|
||||
const FREE_DAILY_LIMIT = 2
|
||||
|
||||
interface PlanConfig {
|
||||
id: string
|
||||
name: string
|
||||
price: number
|
||||
dailyLimit: number
|
||||
features: string[]
|
||||
}
|
||||
|
||||
const PLANS: Record<string, PlanConfig> = {
|
||||
free: {
|
||||
id: 'free',
|
||||
name: '免费版',
|
||||
price: 0,
|
||||
dailyLimit: FREE_DAILY_LIMIT,
|
||||
features: [
|
||||
'每日 2 次 AI 模拟面试',
|
||||
'基础面试报告',
|
||||
'通用题库随机出题',
|
||||
'简历诊断(限 3 次)',
|
||||
],
|
||||
},
|
||||
growth: {
|
||||
id: 'growth',
|
||||
name: '成长版',
|
||||
price: GROWTH_PRICE,
|
||||
dailyLimit: 999,
|
||||
features: [
|
||||
'免费版全部权益',
|
||||
'无限面试次数',
|
||||
'详细面试报告(四维评分)',
|
||||
'进步轨迹雷达图 + 打卡',
|
||||
'每日一题推送',
|
||||
'参考回答思路',
|
||||
'公司真题库',
|
||||
],
|
||||
},
|
||||
}
|
||||
|
||||
@Controller('member')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
export class MemberController {
|
||||
constructor(
|
||||
@InjectModel(User.name) private userModel: Model<UserDocument>,
|
||||
) {}
|
||||
|
||||
// 公开的套餐配置(给前端会员页和限制拦截用)
|
||||
@Public()
|
||||
@Get('plans')
|
||||
getPlans() {
|
||||
return {
|
||||
interview: {
|
||||
dailyFreeLimit: FREE_DAILY_LIMIT,
|
||||
maxRoundsFree: 5,
|
||||
maxRoundsVip: 10,
|
||||
},
|
||||
diagnosis: { dailyFreeLimit: 2 },
|
||||
optimize: { dailyFreeLimit: 2 },
|
||||
price: { monthly: GROWTH_PRICE },
|
||||
plans: Object.values(PLANS).map(p => ({
|
||||
id: p.id,
|
||||
name: p.name,
|
||||
price: p.price,
|
||||
priceDisplay: p.price === 0 ? '免费' : `¥${(p.price / 100).toFixed(1)}/月`,
|
||||
dailyLimit: p.dailyLimit,
|
||||
features: p.features,
|
||||
})),
|
||||
}
|
||||
}
|
||||
|
||||
@Get('status')
|
||||
async getStatus(@CurrentUser('userId') userId: string) {
|
||||
const user = await this.userModel.findById(userId).exec()
|
||||
if (!user) throw new HttpException('用户不存在', HttpStatus.NOT_FOUND)
|
||||
const planConfig = PLANS[user.plan] || PLANS.free
|
||||
return {
|
||||
plan: user.plan,
|
||||
planName: planConfig.name,
|
||||
remaining: user.remaining,
|
||||
dailyLimit: planConfig.dailyLimit,
|
||||
vipExpireAt: user.vipExpireAt,
|
||||
isVip: user.plan !== 'free',
|
||||
}
|
||||
}
|
||||
|
||||
@Post('create-order')
|
||||
async createOrder(@CurrentUser('userId') userId: string) {
|
||||
const user = await this.userModel.findById(userId).exec()
|
||||
if (!user) throw new HttpException('用户不存在', HttpStatus.NOT_FOUND)
|
||||
const orderId = `ZHI${Date.now()}${userId.slice(-4)}`
|
||||
return {
|
||||
orderId,
|
||||
planId: 'growth',
|
||||
planName: '成长版',
|
||||
amount: GROWTH_PRICE,
|
||||
amountDisplay: `¥${(GROWTH_PRICE / 100).toFixed(1)}`,
|
||||
duration: `${DURATION_DAYS} 天`,
|
||||
}
|
||||
}
|
||||
|
||||
@Post('pay')
|
||||
async pay(@CurrentUser('userId') userId: string, @Body('orderId') orderId: string) {
|
||||
const user = await this.userModel.findById(userId).exec()
|
||||
if (!user) throw new HttpException('用户不存在', HttpStatus.NOT_FOUND)
|
||||
const expireAt = new Date()
|
||||
expireAt.setDate(expireAt.getDate() + DURATION_DAYS)
|
||||
user.plan = 'growth'
|
||||
user.vipExpireAt = expireAt
|
||||
user.remaining = 999
|
||||
await user.save()
|
||||
return {
|
||||
success: true,
|
||||
plan: 'growth',
|
||||
planName: '成长版',
|
||||
expireAt,
|
||||
message: '支付成功!欢迎开通成长版',
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { Module } from '@nestjs/common'
|
||||
import { MongooseModule } from '@nestjs/mongoose'
|
||||
import { MemberController } from './member.controller'
|
||||
import { User, UserSchema } from '../user/user.schema'
|
||||
|
||||
@Module({
|
||||
imports: [MongooseModule.forFeature([{ name: User.name, schema: UserSchema }])],
|
||||
controllers: [MemberController],
|
||||
})
|
||||
export class MemberModule {}
|
||||
@@ -0,0 +1,88 @@
|
||||
import { Controller, Post, Body, UseGuards, HttpException, HttpStatus } from '@nestjs/common'
|
||||
import { InjectModel } from '@nestjs/mongoose'
|
||||
import { Model } from 'mongoose'
|
||||
import { User, UserDocument } from '../user/user.schema'
|
||||
import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard'
|
||||
import { CurrentUser } from '../../common/decorators/current-user.decorator'
|
||||
import { WechatPayService } from './wechat-pay.service'
|
||||
import { Public } from '../../common/decorators/public.decorator'
|
||||
|
||||
const VIP_AMOUNT = 2900 // 29 元(分)
|
||||
const VIP_DURATION_DAYS = 30
|
||||
|
||||
@Controller('payment')
|
||||
export class PaymentController {
|
||||
constructor(
|
||||
@InjectModel(User.name) private userModel: Model<UserDocument>,
|
||||
private wechatPay: WechatPayService,
|
||||
) {}
|
||||
|
||||
/** 创建订单(H5:Native 扫码支付) */
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@Post('create')
|
||||
async create(@CurrentUser('userId') userId: string) {
|
||||
const user = await this.userModel.findById(userId).exec()
|
||||
if (!user) throw new HttpException('用户不存在', HttpStatus.NOT_FOUND)
|
||||
if (user.plan === 'vip') throw new HttpException('已是会员', HttpStatus.BAD_REQUEST)
|
||||
|
||||
const outTradeNo = `VIP${Date.now()}${userId.slice(-6)}`
|
||||
const result = await this.wechatPay.nativePay(
|
||||
'职引月度会员',
|
||||
outTradeNo,
|
||||
VIP_AMOUNT,
|
||||
)
|
||||
return {
|
||||
outTradeNo: result.outTradeNo,
|
||||
codeUrl: result.codeUrl, // 二维码链接
|
||||
amount: VIP_AMOUNT,
|
||||
title: '职引月度会员',
|
||||
}
|
||||
}
|
||||
|
||||
/** JSAPI 支付(微信小程序/公众号内使用) */
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@Post('jsapi')
|
||||
async jsapi(@CurrentUser('userId') userId: string, @Body('openid') openid: string) {
|
||||
const user = await this.userModel.findById(userId).exec()
|
||||
if (!user) throw new HttpException('用户不存在', HttpStatus.NOT_FOUND)
|
||||
if (!openid) throw new HttpException('缺少 openid', HttpStatus.BAD_REQUEST)
|
||||
if (user.plan === 'vip') throw new HttpException('已是会员', HttpStatus.BAD_REQUEST)
|
||||
|
||||
const outTradeNo = `VIP${Date.now()}${userId.slice(-6)}`
|
||||
const result = await this.wechatPay.jsapiPay('职引月度会员', outTradeNo, VIP_AMOUNT, openid)
|
||||
return result
|
||||
}
|
||||
|
||||
/** 支付回调通知 */
|
||||
@Public()
|
||||
@Post('notify')
|
||||
async notify(@Body() body: any, @Body('headers') headers: any) {
|
||||
// 实际运行时从 request 读取 header
|
||||
try {
|
||||
const decrypted = this.wechatPay.verifyAndDecrypt(body, '', '', '')
|
||||
if (!decrypted) return { code: 'FAIL', message: '验签失败' }
|
||||
// 处理成功支付
|
||||
const outTradeNo = decrypted.out_trade_no
|
||||
const userId = outTradeNo.slice(-6) // 从订单号取 userId
|
||||
const user = await this.userModel.findOne({ _id: { $regex: userId + '$' } }).exec()
|
||||
if (user && user.plan !== 'vip') {
|
||||
const expireAt = new Date()
|
||||
expireAt.setDate(expireAt.getDate() + VIP_DURATION_DAYS)
|
||||
user.plan = 'vip'
|
||||
user.vipExpireAt = expireAt
|
||||
user.remaining = 999
|
||||
await user.save()
|
||||
}
|
||||
return { code: 'SUCCESS', message: '成功' }
|
||||
} catch {
|
||||
return { code: 'FAIL', message: '处理失败' }
|
||||
}
|
||||
}
|
||||
|
||||
/** 查询订单 */
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@Post('query')
|
||||
async query(@Body('outTradeNo') outTradeNo: string) {
|
||||
return this.wechatPay.queryOrder(outTradeNo)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import { Module } from '@nestjs/common'
|
||||
import { MongooseModule } from '@nestjs/mongoose'
|
||||
import { PaymentController } from './payment.controller'
|
||||
import { WechatPayService } from './wechat-pay.service'
|
||||
import { User, UserSchema } from '../user/user.schema'
|
||||
|
||||
@Module({
|
||||
imports: [MongooseModule.forFeature([{ name: User.name, schema: UserSchema }])],
|
||||
controllers: [PaymentController],
|
||||
providers: [WechatPayService],
|
||||
exports: [WechatPayService],
|
||||
})
|
||||
export class PaymentModule {}
|
||||
@@ -0,0 +1,152 @@
|
||||
import * as fs from 'fs'
|
||||
import * as path from 'path'
|
||||
import * as crypto from 'crypto'
|
||||
import axios from 'axios'
|
||||
import { Injectable, Logger } from '@nestjs/common'
|
||||
|
||||
const MCHID = process.env.WX_MCHID
|
||||
const API_V3_KEY = process.env.WX_API_V3_KEY
|
||||
const NOTIFY_URL = process.env.WX_NOTIFY_URL
|
||||
const APPID = process.env.WX_APPID
|
||||
const WX_API_BASE = 'https://api.mch.weixin.qq.com'
|
||||
|
||||
@Injectable()
|
||||
export class WechatPayService {
|
||||
private readonly logger = new Logger(WechatPayService.name)
|
||||
private readonly privateKey: string
|
||||
private readonly mchSerialNo: string
|
||||
|
||||
constructor() {
|
||||
if (!MCHID || !API_V3_KEY || !APPID) {
|
||||
this.logger.warn('微信支付配置不完整,支付功能不可用')
|
||||
}
|
||||
const certDir = path.resolve(__dirname, '../../certs')
|
||||
this.privateKey = fs.readFileSync(path.join(certDir, 'apiclient_key.pem'), 'utf8')
|
||||
// 从证书中提取序列号
|
||||
const cert = fs.readFileSync(path.join(certDir, 'apiclient_cert.pem'), 'utf8')
|
||||
const certObj = new crypto.X509Certificate(cert)
|
||||
this.mchSerialNo = certObj.serialNumber
|
||||
this.logger.log(`微信支付初始化完成,商户号: ${MCHID}`)
|
||||
}
|
||||
|
||||
/** 生成请求签名(API v3) */
|
||||
private sign(method: string, url: string, body: string, nonce: string, timestamp: string): string {
|
||||
const message = `${method}\n${url}\n${timestamp}\n${nonce}\n${body}\n`
|
||||
return crypto.createSign('RSA-SHA256').update(message).sign(this.privateKey, 'base64')
|
||||
}
|
||||
|
||||
/** 获取 Authorization header */
|
||||
private getAuth(method: string, path: string, body: any) {
|
||||
const nonce = crypto.randomBytes(16).toString('hex')
|
||||
const timestamp = Math.floor(Date.now() / 1000).toString()
|
||||
const bodyStr = body ? JSON.stringify(body) : ''
|
||||
const signature = this.sign(method, path, bodyStr, nonce, timestamp)
|
||||
const auth = `WECHATPAY2-SHA256-RSA2048 mchid="${MCHID}",nonce_str="${nonce}",timestamp="${timestamp}",serial_no="${this.mchSerialNo}",signature="${signature}"`
|
||||
return auth
|
||||
}
|
||||
|
||||
/** 发起 API v3 请求 */
|
||||
private async request(method: string, apiPath: string, body?: any) {
|
||||
const url = `${WX_API_BASE}${apiPath}`
|
||||
try {
|
||||
const res = await axios({
|
||||
method,
|
||||
url,
|
||||
headers: {
|
||||
'Authorization': this.getAuth(method, apiPath, body || ''),
|
||||
'Content-Type': 'application/json',
|
||||
'Accept': 'application/json',
|
||||
'User-Agent': 'zhiyin-backend/1.0',
|
||||
},
|
||||
data: body,
|
||||
})
|
||||
return res.data
|
||||
} catch (e: any) {
|
||||
this.logger.error(`微信支付请求失败: ${method} ${apiPath}`, e.response?.data || e.message)
|
||||
throw e
|
||||
}
|
||||
}
|
||||
|
||||
/** Native 支付:获取二维码链接 */
|
||||
async nativePay(description: string, outTradeNo: string, amount: number, openid?: string) {
|
||||
// amount 单位:分
|
||||
const body: any = {
|
||||
appid: APPID,
|
||||
mchid: MCHID,
|
||||
description,
|
||||
out_trade_no: outTradeNo,
|
||||
notify_url: NOTIFY_URL,
|
||||
amount: { total: amount, currency: 'CNY' },
|
||||
}
|
||||
// JSAPI 时需要 payer.openid
|
||||
if (openid) body.payer = { openid }
|
||||
const apiPath = '/v3/pay/transactions/native'
|
||||
const result = await this.request('POST', apiPath, body)
|
||||
return { codeUrl: result.code_url, outTradeNo }
|
||||
}
|
||||
|
||||
/** JSAPI 支付:获取调起支付的参数 */
|
||||
async jsapiPay(description: string, outTradeNo: string, amount: number, openid: string) {
|
||||
const body = {
|
||||
appid: APPID,
|
||||
mchid: MCHID,
|
||||
description,
|
||||
out_trade_no: outTradeNo,
|
||||
notify_url: NOTIFY_URL,
|
||||
amount: { total: amount, currency: 'CNY' },
|
||||
payer: { openid },
|
||||
}
|
||||
const result = await this.request('POST', '/v3/pay/transactions/jsapi', body)
|
||||
const prepayId = result.prepay_id
|
||||
// 生成小程序/JSAPI 调起支付参数
|
||||
const nonce = crypto.randomBytes(16).toString('hex')
|
||||
const timestamp = Math.floor(Date.now() / 1000).toString()
|
||||
const packageStr = `prepay_id=${prepayId}`
|
||||
const signStr = `${APPID}\n${timestamp}\n${nonce}\n${packageStr}\n`
|
||||
const paySign = crypto.createSign('RSA-SHA256').update(signStr).sign(this.privateKey, 'base64')
|
||||
return {
|
||||
prepayId,
|
||||
payParams: {
|
||||
appId: APPID,
|
||||
timeStamp: timestamp,
|
||||
nonceStr: nonce,
|
||||
package: packageStr,
|
||||
signType: 'RSA',
|
||||
paySign,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/** 验证并解密回调通知 */
|
||||
verifyAndDecrypt(body: any, wechatSignature: string, wechatTimestamp: string, wechatNonce: string) {
|
||||
// 1. 验签
|
||||
const message = `${wechatTimestamp}\n${wechatNonce}\n${JSON.stringify(body)}\n`
|
||||
const certDir = path.resolve(__dirname, '../../certs')
|
||||
const platformCert = fs.readFileSync(path.join(certDir, 'pub_key.pem'), 'utf8')
|
||||
const verify = crypto.createVerify('RSA-SHA256').update(message)
|
||||
const isValid = verify.verify(platformCert, wechatSignature, 'base64')
|
||||
if (!isValid) {
|
||||
this.logger.warn('微信支付回调验签失败')
|
||||
return null
|
||||
}
|
||||
// 2. 解密 resource
|
||||
const resource = body.resource
|
||||
const ciphertext = Buffer.from(resource.ciphertext, 'base64')
|
||||
const associatedData = resource.associated_data || ''
|
||||
const nonce = resource.nonce
|
||||
const key = API_V3_KEY
|
||||
// AES-256-GCM 解密
|
||||
const authTag = ciphertext.subarray(ciphertext.length - 16)
|
||||
const data = ciphertext.subarray(0, ciphertext.length - 16)
|
||||
const decipher = crypto.createDecipheriv('aes-256-gcm', key, nonce)
|
||||
decipher.setAAD(Buffer.from(associatedData))
|
||||
decipher.setAuthTag(authTag)
|
||||
const decrypted = decipher.update(data) + decipher.final('utf8')
|
||||
return JSON.parse(decrypted)
|
||||
}
|
||||
|
||||
/** 查询订单 */
|
||||
async queryOrder(outTradeNo: string) {
|
||||
return this.request('GET', `/v3/pay/transactions/out-trade-no/${outTradeNo}?mchid=${MCHID}`)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import { Controller, Get } from '@nestjs/common'
|
||||
import { Public } from '../../common/decorators/public.decorator'
|
||||
|
||||
@Controller('positions')
|
||||
export class PositionsController {
|
||||
@Public()
|
||||
@Get('hot')
|
||||
hot() {
|
||||
return [
|
||||
{ name: '前端工程师', salary: '15-25K', company: '腾讯' },
|
||||
{ name: '后端工程师', salary: '18-30K', company: '阿里巴巴' },
|
||||
{ name: 'AI 算法工程师', salary: '20-35K', company: '字节跳动' },
|
||||
{ name: '产品经理', salary: '12-20K', company: '美团' },
|
||||
{ name: 'UI 设计师', salary: '10-18K', company: '网易' },
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
import { Module } from '@nestjs/common'
|
||||
import { PositionsController } from './positions.controller'
|
||||
|
||||
@Module({
|
||||
controllers: [PositionsController],
|
||||
})
|
||||
export class PositionsModule {}
|
||||
@@ -0,0 +1,89 @@
|
||||
import { Controller, Get, UseGuards } from '@nestjs/common'
|
||||
import { InjectModel } from '@nestjs/mongoose'
|
||||
import { Model } from 'mongoose'
|
||||
import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard'
|
||||
import { CurrentUser } from '../../common/decorators/current-user.decorator'
|
||||
import { Progress, ProgressDocument } from '../schemas/progress.schema'
|
||||
import { Interview, InterviewDocument } from '../interview/interview.schema'
|
||||
|
||||
@Controller('progress')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
export class ProgressController {
|
||||
constructor(
|
||||
@InjectModel(Progress.name) private progressModel: Model<ProgressDocument>,
|
||||
@InjectModel(Interview.name) private interviewModel: Model<InterviewDocument>,
|
||||
) {}
|
||||
|
||||
@Get()
|
||||
async getProgress(@CurrentUser('userId') userId: string) {
|
||||
let progress = await this.progressModel.findOne({ userId }).exec()
|
||||
if (!progress) {
|
||||
progress = await this.progressModel.create({
|
||||
userId,
|
||||
totalInterviews: 0,
|
||||
completedInterviews: 0,
|
||||
streak: 0,
|
||||
recentScores: [],
|
||||
streakHistory: [],
|
||||
})
|
||||
}
|
||||
|
||||
const recentInterviews = await this.interviewModel
|
||||
.find({ userId, status: 'completed' })
|
||||
.sort({ createdAt: -1 })
|
||||
.limit(10)
|
||||
.select('position totalScore questionCount createdAt')
|
||||
.exec()
|
||||
|
||||
return {
|
||||
totalInterviews: progress.totalInterviews,
|
||||
completedInterviews: progress.completedInterviews,
|
||||
dimensions: {
|
||||
logic: progress.avgLogic || Math.round(60 + Math.random() * 20),
|
||||
expression: progress.avgExpression || Math.round(60 + Math.random() * 20),
|
||||
professionalism: progress.avgProfessionalism || Math.round(60 + Math.random() * 20),
|
||||
stability: progress.avgStability || Math.round(60 + Math.random() * 20),
|
||||
},
|
||||
streak: progress.streak,
|
||||
lastInterviewDate: progress.lastInterviewDate,
|
||||
recentScores: progress.recentScores.slice(-5),
|
||||
interviews: recentInterviews.map(i => ({
|
||||
id: (i as any)._id.toString(),
|
||||
position: i.position,
|
||||
totalScore: i.totalScore,
|
||||
questionCount: i.questionCount,
|
||||
date: (i as any).createdAt,
|
||||
})),
|
||||
}
|
||||
}
|
||||
|
||||
@Get('stats')
|
||||
async getStats(@CurrentUser('userId') userId: string) {
|
||||
const progress = await this.progressModel.findOne({ userId }).exec()
|
||||
if (!progress) {
|
||||
return { totalInterviews: 0, completedInterviews: 0, avgScore: 0, streak: 0 }
|
||||
}
|
||||
|
||||
const completedInterviews = await this.interviewModel
|
||||
.find({ userId, status: 'completed' })
|
||||
.select('totalScore')
|
||||
.exec()
|
||||
|
||||
const avgScore = completedInterviews.length > 0
|
||||
? Math.round(completedInterviews.reduce((s, i) => s + i.totalScore, 0) / completedInterviews.length)
|
||||
: 0
|
||||
|
||||
return {
|
||||
totalInterviews: progress.totalInterviews,
|
||||
completedInterviews: progress.completedInterviews,
|
||||
avgScore,
|
||||
streak: progress.streak,
|
||||
dimensions: {
|
||||
logic: progress.avgLogic || 0,
|
||||
expression: progress.avgExpression || 0,
|
||||
professionalism: progress.avgProfessionalism || 0,
|
||||
stability: progress.avgStability || 0,
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import { Module } from '@nestjs/common'
|
||||
import { MongooseModule } from '@nestjs/mongoose'
|
||||
import { ProgressController } from './progress.controller'
|
||||
import { Progress, ProgressSchema } from '../schemas/progress.schema'
|
||||
import { Interview, InterviewSchema } from '../interview/interview.schema'
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
MongooseModule.forFeature([
|
||||
{ name: Progress.name, schema: ProgressSchema },
|
||||
{ name: Interview.name, schema: InterviewSchema },
|
||||
]),
|
||||
],
|
||||
controllers: [ProgressController],
|
||||
exports: [],
|
||||
})
|
||||
export class ProgressModule {}
|
||||
@@ -0,0 +1,33 @@
|
||||
import { Controller, Post, Get, Delete, Param, Body } from '@nestjs/common'
|
||||
import { ResumeService } from './resume.service'
|
||||
import { CurrentUser } from '../../common/decorators/current-user.decorator'
|
||||
|
||||
@Controller('resume')
|
||||
export class ResumeController {
|
||||
constructor(private resumeService: ResumeService) {}
|
||||
|
||||
@Post('create')
|
||||
async create(
|
||||
@CurrentUser('userId') userId: string,
|
||||
@Body('title') title: string,
|
||||
@Body('content') content: string,
|
||||
@Body('targetPosition') targetPosition?: string,
|
||||
) {
|
||||
return this.resumeService.create(userId, title, content, targetPosition)
|
||||
}
|
||||
|
||||
@Get('list')
|
||||
async list(@CurrentUser('userId') userId: string) {
|
||||
return this.resumeService.list(userId)
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
async getDetail(@Param('id') id: string, @CurrentUser('userId') userId: string) {
|
||||
return this.resumeService.getDetail(id, userId)
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
async delete(@Param('id') id: string, @CurrentUser('userId') userId: string) {
|
||||
return this.resumeService.delete(id, userId)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import { Module } from '@nestjs/common'
|
||||
import { MongooseModule } from '@nestjs/mongoose'
|
||||
import { ResumeController } from './resume.controller'
|
||||
import { ResumeService } from './resume.service'
|
||||
import { Resume, ResumeSchema } from './resume.schema'
|
||||
|
||||
@Module({
|
||||
imports: [MongooseModule.forFeature([{ name: Resume.name, schema: ResumeSchema }])],
|
||||
controllers: [ResumeController],
|
||||
providers: [ResumeService],
|
||||
})
|
||||
export class ResumeModule {}
|
||||
@@ -0,0 +1,22 @@
|
||||
import { Prop, Schema, SchemaFactory } from '@nestjs/mongoose'
|
||||
import { Document, Types } from 'mongoose'
|
||||
|
||||
export type ResumeDocument = Resume & Document
|
||||
|
||||
@Schema({ timestamps: true })
|
||||
export class Resume {
|
||||
@Prop({ type: Types.ObjectId, ref: 'User', required: true })
|
||||
userId: Types.ObjectId
|
||||
|
||||
@Prop({ required: true })
|
||||
title: string
|
||||
|
||||
@Prop({ default: '' })
|
||||
content: string
|
||||
|
||||
@Prop({ default: '' })
|
||||
targetPosition: string
|
||||
}
|
||||
|
||||
export const ResumeSchema = SchemaFactory.createForClass(Resume)
|
||||
ResumeSchema.index({ userId: 1, createdAt: -1 })
|
||||
@@ -0,0 +1,36 @@
|
||||
import { Injectable, HttpException, HttpStatus } from '@nestjs/common'
|
||||
import { InjectModel } from '@nestjs/mongoose'
|
||||
import { Model } from 'mongoose'
|
||||
import { Resume, ResumeDocument } from './resume.schema'
|
||||
|
||||
@Injectable()
|
||||
export class ResumeService {
|
||||
constructor(@InjectModel(Resume.name) private resumeModel: Model<ResumeDocument>) {}
|
||||
|
||||
async create(userId: string, title: string, content: string, targetPosition?: string) {
|
||||
const resume = await this.resumeModel.create({ userId, title, content, targetPosition })
|
||||
return resume.toObject()
|
||||
}
|
||||
|
||||
async list(userId: string) {
|
||||
const list = await this.resumeModel.find({ userId }).sort({ createdAt: -1 }).exec()
|
||||
return list.map(r => ({
|
||||
id: r._id.toString(),
|
||||
title: r.title,
|
||||
targetPosition: r.targetPosition,
|
||||
createdAt: (r as any).createdAt,
|
||||
}))
|
||||
}
|
||||
|
||||
async getDetail(resumeId: string, userId: string) {
|
||||
const resume = await this.resumeModel.findOne({ _id: resumeId, userId }).exec()
|
||||
if (!resume) throw new HttpException('简历不存在', HttpStatus.NOT_FOUND)
|
||||
return resume.toObject()
|
||||
}
|
||||
|
||||
async delete(resumeId: string, userId: string) {
|
||||
const res = await this.resumeModel.deleteOne({ _id: resumeId, userId }).exec()
|
||||
if (res.deletedCount === 0) throw new HttpException('简历不存在', HttpStatus.NOT_FOUND)
|
||||
return { message: '删除成功' }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import { Prop, Schema, SchemaFactory } from '@nestjs/mongoose'
|
||||
import { Document } from 'mongoose'
|
||||
|
||||
export type CompanyBankDocument = CompanyBank & Document
|
||||
|
||||
@Schema({ timestamps: true })
|
||||
export class CompanyBank {
|
||||
@Prop({ required: true })
|
||||
company: string // 公司名称
|
||||
|
||||
@Prop({ required: true })
|
||||
position: string // 岗位
|
||||
|
||||
@Prop({ type: [Object], default: [] })
|
||||
questions: Array<{
|
||||
content: string // 问题内容
|
||||
type: string // basic/algorithm/project/behavioral
|
||||
referenceAnswer: string // 参考回答
|
||||
difficulty: string // easy/medium/hard
|
||||
frequency: number // 出现频率统计
|
||||
tags: string[] // 标签
|
||||
}>
|
||||
|
||||
@Prop({ default: 0 })
|
||||
contributionCount: number // 用户贡献次数
|
||||
|
||||
@Prop({ default: 0 })
|
||||
viewCount: number // 查看次数
|
||||
}
|
||||
|
||||
export const CompanyBankSchema = SchemaFactory.createForClass(CompanyBank)
|
||||
@@ -0,0 +1,36 @@
|
||||
import { Prop, Schema, SchemaFactory } from '@nestjs/mongoose'
|
||||
import { Document } from 'mongoose'
|
||||
|
||||
export type ContributionDocument = Contribution & Document
|
||||
|
||||
@Schema({ timestamps: true })
|
||||
export class Contribution {
|
||||
@Prop({ required: true, index: true })
|
||||
userId: string
|
||||
|
||||
@Prop({ required: true })
|
||||
interviewId: string
|
||||
|
||||
@Prop({ required: true })
|
||||
company: string // 公司名称
|
||||
|
||||
@Prop({ required: true })
|
||||
position: string // 岗位
|
||||
|
||||
@Prop({ default: '' })
|
||||
rounds: string // 面试轮次描述
|
||||
|
||||
@Prop({ type: [String], default: [] })
|
||||
questions: string[] // 遇到的面试题(脱敏)
|
||||
|
||||
@Prop({ default: '' })
|
||||
experience: string // 面试经验/感受
|
||||
|
||||
@Prop({ type: [String], default: [] })
|
||||
tags: string[] // 标签(如"算法题多"、"重视项目经历")
|
||||
|
||||
@Prop({ default: false })
|
||||
verified: boolean // 是否经过审核
|
||||
}
|
||||
|
||||
export const ContributionSchema = SchemaFactory.createForClass(Contribution)
|
||||
@@ -0,0 +1,27 @@
|
||||
import { Prop, Schema, SchemaFactory } from '@nestjs/mongoose'
|
||||
import { Document } from 'mongoose'
|
||||
|
||||
export type DailyQuestionDocument = DailyQuestion & Document
|
||||
|
||||
@Schema({ timestamps: true })
|
||||
export class DailyQuestion {
|
||||
@Prop({ required: true })
|
||||
position: string // 适用岗位
|
||||
|
||||
@Prop({ required: true })
|
||||
question: string // 面试题
|
||||
|
||||
@Prop({ required: true })
|
||||
referenceAnswer: string // 参考思路
|
||||
|
||||
@Prop({ default: 'general' })
|
||||
category: string // basic/algorithm/project/behavioral
|
||||
|
||||
@Prop()
|
||||
date?: Date // 推送日期
|
||||
|
||||
@Prop({ default: false })
|
||||
pushed: boolean // 是否已推送
|
||||
}
|
||||
|
||||
export const DailyQuestionSchema = SchemaFactory.createForClass(DailyQuestion)
|
||||
@@ -0,0 +1,56 @@
|
||||
import { Prop, Schema, SchemaFactory } from '@nestjs/mongoose'
|
||||
import { Document } from 'mongoose'
|
||||
|
||||
export type ProgressDocument = Progress & Document
|
||||
|
||||
@Schema({ timestamps: true })
|
||||
export class Progress {
|
||||
@Prop({ required: true, index: true })
|
||||
userId: string
|
||||
|
||||
@Prop({ default: 0 })
|
||||
totalInterviews: number
|
||||
|
||||
@Prop({ default: 0 })
|
||||
completedInterviews: number
|
||||
|
||||
// 四维能力平均分
|
||||
@Prop({ default: 0 })
|
||||
avgLogic: number // 逻辑思维
|
||||
|
||||
@Prop({ default: 0 })
|
||||
avgExpression: number // 表达能力
|
||||
|
||||
@Prop({ default: 0 })
|
||||
avgProfessionalism: number // 专业度
|
||||
|
||||
@Prop({ default: 0 })
|
||||
avgStability: number // 稳定性
|
||||
|
||||
// 最近面试记录(用于进步对比)
|
||||
@Prop({ type: [Object], default: [] })
|
||||
recentScores: Array<{
|
||||
interviewId: string
|
||||
date: Date
|
||||
position: string
|
||||
totalScore: number
|
||||
dimensions: {
|
||||
logic: number
|
||||
expression: number
|
||||
professionalism: number
|
||||
stability: number
|
||||
}
|
||||
}>
|
||||
|
||||
// 打卡记录
|
||||
@Prop({ default: 0 })
|
||||
streak: number // 连续打卡天数
|
||||
|
||||
@Prop()
|
||||
lastInterviewDate?: Date
|
||||
|
||||
@Prop({ type: [Date], default: [] })
|
||||
streakHistory: Date[]
|
||||
}
|
||||
|
||||
export const ProgressSchema = SchemaFactory.createForClass(Progress)
|
||||
@@ -0,0 +1,60 @@
|
||||
import { Controller, Post, UseInterceptors, UploadedFile, HttpException, HttpStatus } from '@nestjs/common'
|
||||
import { FileInterceptor } from '@nestjs/platform-express'
|
||||
import * as mammoth from 'mammoth'
|
||||
import { memoryStorage } from 'multer'
|
||||
import { Public } from '../../common/decorators/public.decorator'
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
||||
const pdfParse = require('pdf-parse')
|
||||
|
||||
@Controller('upload')
|
||||
export class UploadController {
|
||||
@Public()
|
||||
@Post()
|
||||
@UseInterceptors(FileInterceptor('file', {
|
||||
storage: memoryStorage(),
|
||||
limits: { fileSize: 10 * 1024 * 1024 },
|
||||
}))
|
||||
async uploadFile(@UploadedFile() file: any) {
|
||||
if (!file) {
|
||||
throw new HttpException('请选择文件', HttpStatus.BAD_REQUEST)
|
||||
}
|
||||
|
||||
const ext = file.originalname.toLowerCase().split('.').pop() || ''
|
||||
let text = ''
|
||||
|
||||
try {
|
||||
switch (ext) {
|
||||
case 'pdf':
|
||||
const pdfData = await pdfParse(file.buffer)
|
||||
text = pdfData.text
|
||||
break
|
||||
case 'docx':
|
||||
const docxResult = await mammoth.extractRawText({ buffer: file.buffer })
|
||||
text = docxResult.value
|
||||
break
|
||||
case 'doc':
|
||||
text = '[旧版 Word 格式 (.doc) 暂不支持解析,请转换为 .docx 或粘贴文本]'
|
||||
break
|
||||
case 'txt':
|
||||
text = file.buffer.toString('utf-8')
|
||||
break
|
||||
default:
|
||||
throw new HttpException('不支持的文件格式,请上传 PDF、DOCX 或 TXT', HttpStatus.BAD_REQUEST)
|
||||
}
|
||||
} catch (e: any) {
|
||||
if (e instanceof HttpException) throw e
|
||||
throw new HttpException('文件解析失败:' + (e.message || '未知错误'), HttpStatus.INTERNAL_SERVER_ERROR)
|
||||
}
|
||||
|
||||
if (!text.trim()) {
|
||||
throw new HttpException('未能从文件中提取到有效文本', HttpStatus.BAD_REQUEST)
|
||||
}
|
||||
|
||||
return {
|
||||
text: text.trim(),
|
||||
fileName: file.originalname,
|
||||
fileSize: file.size,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import { Module } from '@nestjs/common'
|
||||
import { MulterModule } from '@nestjs/platform-express'
|
||||
import { memoryStorage } from 'multer'
|
||||
import { UploadController } from './upload.controller'
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
MulterModule.register({ storage: memoryStorage() }),
|
||||
],
|
||||
controllers: [UploadController],
|
||||
})
|
||||
export class UploadModule {}
|
||||
@@ -0,0 +1,56 @@
|
||||
import { Controller, Post, Get, Put, Body, Req } from '@nestjs/common'
|
||||
import { UserService } from './user.service'
|
||||
import { Public } from '../../common/decorators/public.decorator'
|
||||
import { CurrentUser } from '../../common/decorators/current-user.decorator'
|
||||
|
||||
@Controller('user')
|
||||
export class UserController {
|
||||
constructor(private userService: UserService) {}
|
||||
|
||||
@Public()
|
||||
@Post('send-code')
|
||||
async sendCode(@Body('phone') phone: string) {
|
||||
return this.userService.sendCode(phone)
|
||||
}
|
||||
|
||||
@Public()
|
||||
@Post('login')
|
||||
async login(@Body('phone') phone: string, @Body('code') code: string) {
|
||||
return this.userService.loginByPhone(phone, code)
|
||||
}
|
||||
|
||||
// 📧 邮箱验证码登录(H5 用)
|
||||
@Public()
|
||||
@Post('send-email-code')
|
||||
async sendEmailCode(@Body('email') email: string) {
|
||||
return this.userService.sendEmailCode(email)
|
||||
}
|
||||
|
||||
@Public()
|
||||
@Post('email-login')
|
||||
async emailLogin(@Body('email') email: string, @Body('code') code: string) {
|
||||
return this.userService.loginByEmail(email, code)
|
||||
}
|
||||
|
||||
// 微信静默登录
|
||||
@Public()
|
||||
@Post('wx-login')
|
||||
async wxLogin(@Body('code') code: string) {
|
||||
return this.userService.loginByWx(code)
|
||||
}
|
||||
|
||||
@Get('info')
|
||||
async getInfo(@CurrentUser('userId') userId: string) {
|
||||
return this.userService.getInfo(userId)
|
||||
}
|
||||
|
||||
@Put('update')
|
||||
async update(@CurrentUser('userId') userId: string, @Body() data: { nickname?: string; avatar?: string }) {
|
||||
return this.userService.update(userId, data)
|
||||
}
|
||||
|
||||
@Get('usage')
|
||||
async getUsage(@CurrentUser('userId') userId: string) {
|
||||
return this.userService.getUsage(userId)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import { Module } from '@nestjs/common'
|
||||
import { MongooseModule } from '@nestjs/mongoose'
|
||||
import { JwtModule } from '@nestjs/jwt'
|
||||
import { UserController } from './user.controller'
|
||||
import { UserService } from './user.service'
|
||||
import { User, UserSchema } from './user.schema'
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
MongooseModule.forFeature([{ name: User.name, schema: UserSchema }]),
|
||||
JwtModule.register({
|
||||
secret: process.env.JWT_SECRET || 'zhiyin-jwt-secret-2026',
|
||||
signOptions: { expiresIn: '7d' },
|
||||
}),
|
||||
],
|
||||
controllers: [UserController],
|
||||
providers: [UserService],
|
||||
exports: [UserService],
|
||||
})
|
||||
export class UserModule {}
|
||||
@@ -0,0 +1,42 @@
|
||||
import { Prop, Schema, SchemaFactory } from '@nestjs/mongoose'
|
||||
import { Document } from 'mongoose'
|
||||
|
||||
export type UserDocument = User & Document
|
||||
|
||||
@Schema({ timestamps: true })
|
||||
export class User {
|
||||
@Prop({ unique: true, sparse: true })
|
||||
phone?: string
|
||||
|
||||
@Prop({ unique: true, sparse: true })
|
||||
wxOpenid?: string
|
||||
|
||||
@Prop({ default: '' })
|
||||
nickname?: string
|
||||
|
||||
@Prop({ default: '' })
|
||||
avatar?: string
|
||||
|
||||
@Prop({ default: 0 })
|
||||
interviewCount: number
|
||||
|
||||
@Prop({ default: 3 })
|
||||
remaining: number
|
||||
|
||||
@Prop({ default: 'free' })
|
||||
plan: string
|
||||
|
||||
@Prop()
|
||||
vipExpireAt?: Date
|
||||
|
||||
@Prop({ default: 'user' })
|
||||
role: string // 'user' | 'admin'
|
||||
|
||||
@Prop({ default: false })
|
||||
isSystemAdmin: boolean
|
||||
|
||||
@Prop({ unique: true, sparse: true })
|
||||
email?: string
|
||||
}
|
||||
|
||||
export const UserSchema = SchemaFactory.createForClass(User)
|
||||
@@ -0,0 +1,165 @@
|
||||
import { Injectable, HttpException, HttpStatus } from '@nestjs/common'
|
||||
import { InjectModel } from '@nestjs/mongoose'
|
||||
import { Model } from 'mongoose'
|
||||
import { JwtService } from '@nestjs/jwt'
|
||||
import { User, UserDocument } from './user.schema'
|
||||
import { EmailService } from '../email/email.service'
|
||||
|
||||
// In-memory stores
|
||||
const codeStore = new Map<string, { code: string; expiresAt: number }>()
|
||||
const emailCodeStore = new Map<string, { code: string; expiresAt: number }>()
|
||||
|
||||
@Injectable()
|
||||
export class UserService {
|
||||
constructor(
|
||||
@InjectModel(User.name) private userModel: Model<UserDocument>,
|
||||
private jwtService: JwtService,
|
||||
private emailService: EmailService,
|
||||
) {}
|
||||
|
||||
async sendCode(phone: string) {
|
||||
const code = process.env.NODE_ENV === 'production'
|
||||
? String(Math.floor(100000 + Math.random() * 900000))
|
||||
: '123456'
|
||||
|
||||
codeStore.set(phone, { code, expiresAt: Date.now() + 5 * 60 * 1000 })
|
||||
|
||||
if (process.env.NODE_ENV !== 'production') {
|
||||
console.log(`[DEV] Verification code for ${phone}: ${code}`)
|
||||
}
|
||||
return { message: '验证码已发送' }
|
||||
}
|
||||
|
||||
async loginByPhone(phone: string, code: string) {
|
||||
const record = codeStore.get(phone)
|
||||
if (!record || record.code !== code) {
|
||||
throw new HttpException('验证码错误', HttpStatus.UNAUTHORIZED)
|
||||
}
|
||||
if (Date.now() > record.expiresAt) {
|
||||
codeStore.delete(phone)
|
||||
throw new HttpException('验证码已过期', HttpStatus.UNAUTHORIZED)
|
||||
}
|
||||
codeStore.delete(phone)
|
||||
|
||||
let user = await this.userModel.findOne({ phone }).exec()
|
||||
if (!user) {
|
||||
user = await this.userModel.create({ phone, nickname: `用户${phone.slice(-4)}` })
|
||||
}
|
||||
|
||||
return this.generateAuthResponse(user)
|
||||
}
|
||||
|
||||
async loginByWx(code: string) {
|
||||
// WeChat silent login - exchange code for openid
|
||||
const appid = process.env.WX_APPID
|
||||
const secret = process.env.WX_SECRET
|
||||
if (!appid || !secret) {
|
||||
throw new HttpException('微信登录未配置', HttpStatus.SERVICE_UNAVAILABLE)
|
||||
}
|
||||
|
||||
const wxRes = await fetch(
|
||||
`https://api.weixin.qq.com/sns/jscode2session?appid=${appid}&secret=${secret}&js_code=${code}&grant_type=authorization_code`,
|
||||
)
|
||||
const wxData: any = await wxRes.json()
|
||||
|
||||
if (wxData.errcode) {
|
||||
throw new HttpException(`微信登录失败: ${wxData.errmsg}`, HttpStatus.UNAUTHORIZED)
|
||||
}
|
||||
|
||||
const openid = wxData.openid
|
||||
let user = await this.userModel.findOne({ wxOpenid: openid }).exec()
|
||||
if (!user) {
|
||||
user = await this.userModel.create({ wxOpenid: openid, nickname: '微信用户' })
|
||||
}
|
||||
|
||||
return this.generateAuthResponse(user)
|
||||
}
|
||||
|
||||
// 📧 邮箱验证码
|
||||
async sendEmailCode(email: string) {
|
||||
if (!email || !email.includes('@')) {
|
||||
throw new HttpException('请输入正确的邮箱地址', HttpStatus.BAD_REQUEST)
|
||||
}
|
||||
const code = String(Math.floor(100000 + Math.random() * 900000))
|
||||
emailCodeStore.set(email, { code, expiresAt: Date.now() + 10 * 60 * 1000 })
|
||||
const sent = await this.emailService.sendVerificationCode(email, code)
|
||||
if (sent) {
|
||||
return { message: '验证码已发送到邮箱' }
|
||||
}
|
||||
// 邮件发送失败时返回 devCode 方便调试
|
||||
console.log(`[EMAIL] Dev code for ${email}: ${code}`)
|
||||
return { message: '验证码已发送(邮件服务暂不可用,开发模式)', devCode: code }
|
||||
}
|
||||
|
||||
async loginByEmail(email: string, code: string) {
|
||||
const record = emailCodeStore.get(email)
|
||||
if (!record || record.code !== code) {
|
||||
throw new HttpException('验证码错误', HttpStatus.UNAUTHORIZED)
|
||||
}
|
||||
if (Date.now() > record.expiresAt) {
|
||||
emailCodeStore.delete(email)
|
||||
throw new HttpException('验证码已过期', HttpStatus.UNAUTHORIZED)
|
||||
}
|
||||
emailCodeStore.delete(email)
|
||||
|
||||
// 按邮箱查找或创建用户
|
||||
let user = await this.userModel.findOne({ email }).exec()
|
||||
if (!user) {
|
||||
const nick = email.split('@')[0]
|
||||
user = await this.userModel.create({ email, nickname: nick, remaining: 3 })
|
||||
}
|
||||
return this.generateAuthResponse(user)
|
||||
}
|
||||
|
||||
async getInfo(userId: string) {
|
||||
const user = await this.userModel.findById(userId).exec()
|
||||
if (!user) throw new HttpException('用户不存在', HttpStatus.NOT_FOUND)
|
||||
return this.safeUser(user)
|
||||
}
|
||||
|
||||
async update(userId: string, data: { nickname?: string; avatar?: string }) {
|
||||
const user = await this.userModel.findByIdAndUpdate(userId, data, { new: true }).exec()
|
||||
if (!user) throw new HttpException('用户不存在', HttpStatus.NOT_FOUND)
|
||||
return this.safeUser(user)
|
||||
}
|
||||
|
||||
getModel() { return this.userModel }
|
||||
|
||||
async getUsage(userId: string) {
|
||||
const user = await this.userModel.findById(userId).exec()
|
||||
if (!user) throw new HttpException('用户不存在', HttpStatus.NOT_FOUND)
|
||||
return { remaining: user.remaining, plan: user.plan, interviewCount: user.interviewCount }
|
||||
}
|
||||
|
||||
async deductRemaining(userId: string) {
|
||||
const user = await this.userModel.findById(userId).exec()
|
||||
if (!user) throw new HttpException('用户不存在', HttpStatus.NOT_FOUND)
|
||||
if (user.remaining <= 0) throw new HttpException('使用次数已用完', HttpStatus.FORBIDDEN)
|
||||
user.remaining -= 1
|
||||
user.interviewCount += 1
|
||||
await user.save()
|
||||
}
|
||||
|
||||
private generateAuthResponse(user: UserDocument) {
|
||||
const payload = { userId: user._id.toString(), phone: user.phone || '' }
|
||||
return {
|
||||
token: this.jwtService.sign(payload),
|
||||
user: this.safeUser(user),
|
||||
}
|
||||
}
|
||||
|
||||
private safeUser(user: UserDocument) {
|
||||
return {
|
||||
id: user._id.toString(),
|
||||
phone: user.phone || '',
|
||||
email: user.email || '',
|
||||
nickname: user.nickname || '',
|
||||
avatar: user.avatar || '',
|
||||
plan: user.plan,
|
||||
role: user.role || 'user',
|
||||
isSystemAdmin: user.isSystemAdmin || false,
|
||||
remaining: user.remaining,
|
||||
interviewCount: user.interviewCount,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"module": "commonjs",
|
||||
"declaration": true,
|
||||
"removeComments": true,
|
||||
"emitDecoratorMetadata": true,
|
||||
"experimentalDecorators": true,
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"esModuleInterop": true,
|
||||
"target": "ES2021",
|
||||
"sourceMap": true,
|
||||
"outDir": "./dist",
|
||||
"baseUrl": "./",
|
||||
"incremental": true,
|
||||
"skipLibCheck": true,
|
||||
"strictNullChecks": true,
|
||||
"noImplicitAny": false,
|
||||
"strictBindCallApply": false,
|
||||
"forceConsistentCasingInFileNames": false,
|
||||
"noFallthroughCasesInSwitch": false,
|
||||
"paths": {
|
||||
"@/*": ["src/*"]
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user