diff --git a/backend/package.json b/backend/package.json index 2ef38d6..44ef29a 100644 --- a/backend/package.json +++ b/backend/package.json @@ -2,13 +2,13 @@ "name": "zhiyin-backend", "version": "1.0.0", "description": "职引 - AI简历优化后端服务", - "main": "dist/main.js", + "main": "dist/src/main.js", "scripts": { "start": "nest start", "start:dev": "nest start --watch", - "start:prod": "node dist/main", + "start:prod": "node dist/src/main", "build": "nest build", - "postbuild": "node -e \"const fs=require('fs');if(fs.existsSync('certs')){fs.cpSync('certs','dist/certs',{recursive:true})}\"", + "postbuild": "node -e \"const fs=require('fs');if(fs.existsSync('certs')){fs.cpSync('certs','dist/src/certs',{recursive:true})}\"", "test": "jest --forceExit --detectOpenHandles", "test:watch": "jest --watch --forceExit", "test:cov": "jest --coverage --forceExit", diff --git a/backend/src/common/guards/admin.guard.ts b/backend/src/common/guards/admin.guard.ts new file mode 100644 index 0000000..fbd9b21 --- /dev/null +++ b/backend/src/common/guards/admin.guard.ts @@ -0,0 +1,16 @@ +import { Injectable, CanActivate, ExecutionContext, HttpException, HttpStatus } from '@nestjs/common' + +@Injectable() +export class AdminGuard implements CanActivate { + canActivate(context: ExecutionContext): boolean { + const request = context.switchToHttp().getRequest() + const user = request.user + if (!user) { + throw new HttpException('未登录', HttpStatus.UNAUTHORIZED) + } + if (user.role !== 'admin') { + throw new HttpException('无权限访问', HttpStatus.FORBIDDEN) + } + return true + } +} diff --git a/backend/src/common/strategies/jwt.strategy.ts b/backend/src/common/strategies/jwt.strategy.ts index 53cd6ee..8ac2247 100644 --- a/backend/src/common/strategies/jwt.strategy.ts +++ b/backend/src/common/strategies/jwt.strategy.ts @@ -12,7 +12,7 @@ export class JwtStrategy extends PassportStrategy(Strategy) { }) } - async validate(payload: { userId: string; phone: string }) { - return { userId: payload.userId, phone: payload.phone } + async validate(payload: { userId: string; phone: string; role?: string }) { + return { userId: payload.userId, phone: payload.phone, role: payload.role || 'user' } } } diff --git a/backend/src/main.ts b/backend/src/main.ts index 280956e..0f2f3b3 100644 --- a/backend/src/main.ts +++ b/backend/src/main.ts @@ -42,10 +42,10 @@ async function bootstrap() { const app = await NestFactory.create(AppModule) app.setGlobalPrefix('api') - const allowedOrigins = process.env.CORS_ORIGINS?.split(',') || ['http://localhost:8085', 'http://localhost:3006'] + const allowedOrigins = process.env.CORS_ORIGINS?.split(',') || ['http://localhost:8085', 'http://localhost:3006', 'https://zhiyin.yzrcloud.cn'] app.enableCors({ origin: (origin, callback) => { - if (!origin || allowedOrigins.includes(origin) || process.env.NODE_ENV !== 'production') { + if (!origin || allowedOrigins.includes(origin)) { callback(null, true) } else { callback(new Error('Not allowed by CORS')) diff --git a/backend/src/modules/admin/admin.controller.ts b/backend/src/modules/admin/admin.controller.ts index b1dc1fd..85a401d 100644 --- a/backend/src/modules/admin/admin.controller.ts +++ b/backend/src/modules/admin/admin.controller.ts @@ -2,6 +2,7 @@ import { Controller, Get, Post, Body, Query, HttpException, HttpStatus, UseGuard import { InjectModel } from '@nestjs/mongoose' import { Model } from 'mongoose' import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard' +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' @@ -11,6 +12,7 @@ import { WechatPayService } from '../payment/wechat-pay.service' const VIP_DURATION_DAYS = 30 +@UseGuards(JwtAuthGuard, AdminGuard) @Controller('admin') export class AdminController { constructor( @@ -22,27 +24,18 @@ export class AdminController { ) {} @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' } + async checkAdmin(@CurrentUser('role') role: string) { + return { isAdmin: role === 'admin' } } - @UseGuards(JwtAuthGuard) @Post('verify') async verify(@CurrentUser('userId') userId: string) { - const user = await this.userModel.findById(userId).exec() - if (!user || user.role !== 'admin') { - throw new HttpException('无权限访问', HttpStatus.FORBIDDEN) - } - return { ok: true, nickname: user.nickname || '管理员' } + const user = await this.userModel.findById(userId).select('nickname').exec() + return { ok: true, nickname: user?.nickname || '管理员' } } - @UseGuards(JwtAuthGuard) @Get('overview') - async overview(@CurrentUser('userId') adminUserId: string) { - const admin = await this.userModel.findById(adminUserId).exec() - if (admin?.role !== 'admin') throw new HttpException('无权限', HttpStatus.FORBIDDEN) + async overview() { const [userCount, interviewCount, todayUsers, todayInterviews] = await Promise.all([ this.userModel.countDocuments().exec(), this.interviewModel.countDocuments().exec(), @@ -52,13 +45,11 @@ export class AdminController { return { userCount, interviewCount, todayUsers, todayInterviews } } - @UseGuards(JwtAuthGuard) @Get('users') - async getUsers(@Query('keyword') keyword: string, @Query('page') page = '1', @Query('limit') limit = '20', @CurrentUser('userId') adminUserId: string) { - const admin = await this.userModel.findById(adminUserId).exec() - if (admin?.role !== 'admin') throw new HttpException('无权限', HttpStatus.FORBIDDEN) + async getUsers(@Query('keyword') keyword: string, @Query('page') page = '1', @Query('limit') limit = '20') { const filter: any = {} if (keyword) { + if (keyword.length > 50) throw new HttpException('关键词过长', HttpStatus.BAD_REQUEST) const escaped = keyword.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') filter.$or = [ { phone: { $regex: escaped, $options: 'i' } }, @@ -67,17 +58,14 @@ export class AdminController { } 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.find(filter).sort({ createdAt: -1 }).skip(skip).limit(+limit).lean().exec(), this.userModel.countDocuments(filter).exec(), ]) return { users, total, page: +page } } - @UseGuards(JwtAuthGuard) @Get('interviews') - async getInterviews(@Query('page') page = '1', @Query('limit') limit = '20', @CurrentUser('userId') adminUserId: string) { - const admin = await this.userModel.findById(adminUserId).exec() - if (admin?.role !== 'admin') throw new HttpException('无权限', HttpStatus.FORBIDDEN) + 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(), @@ -86,11 +74,8 @@ export class AdminController { 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) + async setVip(@Body('userId') targetUserId: string) { const user = await this.userModel.findById(targetUserId).exec() if (!user) throw new HttpException('用户不存在', HttpStatus.NOT_FOUND) const expireAt = new Date() @@ -102,20 +87,14 @@ export class AdminController { return { success: true, plan: 'growth', 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) + async getAdmins() { 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) + async setAdmin(@Body('userId') targetUserId: string) { 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) @@ -124,13 +103,8 @@ export class AdminController { return { success: true, message: '已设为管理员' } } - // ─── 订单管理 ────────────── - - @UseGuards(JwtAuthGuard) @Get('orders') - async getOrders(@Query('page') page = '1', @Query('limit') limit = '20', @Query('status') status: string, @CurrentUser('userId') adminUserId: string) { - const admin = await this.userModel.findById(adminUserId).exec() - if (admin?.role !== 'admin') throw new HttpException('无权限', HttpStatus.FORBIDDEN) + async getOrders(@Query('page') page = '1', @Query('limit') limit = '20', @Query('status') status: string) { const filter: any = {} if (status) filter.status = status const skip = (Math.max(1, +page) - 1) * +limit @@ -141,11 +115,8 @@ export class AdminController { return { orders, total, page: +page } } - @UseGuards(JwtAuthGuard) @Post('order/sync') - async syncOrder(@Body('outTradeNo') outTradeNo: string, @CurrentUser('userId') adminUserId: string) { - const admin = await this.userModel.findById(adminUserId).exec() - if (admin?.role !== 'admin') throw new HttpException('无权限', HttpStatus.FORBIDDEN) + async syncOrder(@Body('outTradeNo') outTradeNo: string) { const wxResult = await this.wechatPay.queryOrder(outTradeNo) const tradeState = wxResult?.trade_state const order = await this.orderModel.findOne({ outTradeNo }).exec() @@ -168,24 +139,15 @@ export class AdminController { return { order, wxResult } } - // ─── 系统配置(支持管理后台编辑) ────── - - @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) - // 从数据库读,不存在则返回默认值 + async getConfig() { const cfg = await this.configModel.findOne({ key: 'site_config' }).exec() if (cfg) return cfg.value return DEFAULT_CONFIG } - @UseGuards(JwtAuthGuard) @Post('config/save') - async saveConfig(@Body() body: any, @CurrentUser('userId') adminUserId: string) { - const admin = await this.userModel.findById(adminUserId).exec() - if (admin?.role !== 'admin') throw new HttpException('无权限', HttpStatus.FORBIDDEN) + async saveConfig(@Body() body: any) { await this.configModel.findOneAndUpdate( { key: 'site_config' }, { key: 'site_config', value: body, description: '站点配置' }, @@ -194,22 +156,14 @@ export class AdminController { return { success: true } } - // ─── 每日一题管理 ────────────── - - @UseGuards(JwtAuthGuard) @Get('questions') - async getQuestions(@CurrentUser('userId') adminUserId: string) { - const admin = await this.userModel.findById(adminUserId).exec() - if (admin?.role !== 'admin') throw new HttpException('无权限', HttpStatus.FORBIDDEN) + async getQuestions() { const cfg = await this.configModel.findOne({ key: 'daily_questions' }).exec() return cfg?.value || DEFAULT_QUESTIONS } - @UseGuards(JwtAuthGuard) @Post('questions/save') - async saveQuestions(@Body() body: any, @CurrentUser('userId') adminUserId: string) { - const admin = await this.userModel.findById(adminUserId).exec() - if (admin?.role !== 'admin') throw new HttpException('无权限', HttpStatus.FORBIDDEN) + async saveQuestions(@Body() body: any) { await this.configModel.findOneAndUpdate( { key: 'daily_questions' }, { key: 'daily_questions', value: body, description: '每日一题题库' }, diff --git a/backend/src/modules/admin/admin.module.ts b/backend/src/modules/admin/admin.module.ts index 124beb5..92b139c 100644 --- a/backend/src/modules/admin/admin.module.ts +++ b/backend/src/modules/admin/admin.module.ts @@ -5,7 +5,7 @@ import { User, UserSchema } from '../user/user.schema' import { Interview, InterviewSchema } from '../interview/interview.schema' import { PaymentOrder, PaymentOrderSchema } from '../payment/payment-order.schema' import { WechatPayService } from '../payment/wechat-pay.service' - +import { AdminGuard } from '../../common/guards/admin.guard' import { SiteConfig, SiteConfigSchema } from '../schemas/site-config.schema' @Module({ @@ -18,6 +18,6 @@ import { SiteConfig, SiteConfigSchema } from '../schemas/site-config.schema' ]), ], controllers: [AdminController], - providers: [WechatPayService], + providers: [WechatPayService, AdminGuard], }) export class AdminModule {} diff --git a/backend/src/modules/upload/upload.controller.ts b/backend/src/modules/upload/upload.controller.ts index 4df7663..fc7a970 100644 --- a/backend/src/modules/upload/upload.controller.ts +++ b/backend/src/modules/upload/upload.controller.ts @@ -2,14 +2,12 @@ import { Controller, Post, UseInterceptors, UploadedFile, HttpException, HttpSta 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(), diff --git a/backend/src/modules/user/user.schema.ts b/backend/src/modules/user/user.schema.ts index 468e934..811699a 100644 --- a/backend/src/modules/user/user.schema.ts +++ b/backend/src/modules/user/user.schema.ts @@ -44,7 +44,7 @@ export class User { @Prop({ sparse: true }) email?: string - @Prop({ default: '' }) + @Prop({ default: '', select: false }) password?: string } diff --git a/backend/src/modules/user/user.service.spec.ts b/backend/src/modules/user/user.service.spec.ts index d947968..0d6645d 100644 --- a/backend/src/modules/user/user.service.spec.ts +++ b/backend/src/modules/user/user.service.spec.ts @@ -26,12 +26,12 @@ describe('UserService', () => { } beforeEach(async () => { + const chainable = (value: any) => ({ exec: jest.fn().mockResolvedValue(value), select: jest.fn().mockReturnThis() }) mockUserModel = { - findOne: jest.fn().mockReturnThis(), - findById: jest.fn().mockReturnThis(), - findByIdAndUpdate: jest.fn().mockReturnThis(), + findOne: jest.fn().mockReturnValue(chainable(null)), + findById: jest.fn().mockReturnValue(chainable(null)), + findByIdAndUpdate: jest.fn().mockReturnValue(chainable(null)), create: jest.fn().mockResolvedValue(mockUser), - exec: jest.fn().mockResolvedValue(null), } mockJwtService = { sign: jest.fn().mockReturnValue('mock-jwt-token'), @@ -70,7 +70,7 @@ describe('UserService', () => { }) it('should create user on first login and return token', async () => { - mockUserModel.findOne.mockReturnValueOnce({ exec: jest.fn().mockResolvedValue(null) }) + mockUserModel.findOne.mockReturnValueOnce({ exec: jest.fn().mockResolvedValue(null), select: jest.fn().mockReturnThis() }) await service.sendCode('13800138000') const result = await service.loginByPhone('13800138000', '123456') expect(result).toHaveProperty('token', 'mock-jwt-token') @@ -79,7 +79,7 @@ describe('UserService', () => { }) it('should login existing user', async () => { - mockUserModel.findOne.mockReturnValueOnce({ exec: jest.fn().mockResolvedValue(mockUser) }) + mockUserModel.findOne.mockReturnValueOnce({ exec: jest.fn().mockResolvedValue(mockUser), select: jest.fn().mockReturnThis() }) await service.sendCode('13800138000') const result = await service.loginByPhone('13800138000', '123456') expect(result).toHaveProperty('token') @@ -105,7 +105,7 @@ describe('UserService', () => { }) it('should login with valid email code', async () => { - mockUserModel.findOne.mockReturnValueOnce({ exec: jest.fn().mockResolvedValue(mockUser) }) + mockUserModel.findOne.mockReturnValueOnce({ exec: jest.fn().mockResolvedValue(mockUser), select: jest.fn().mockReturnThis() }) const spy = jest.spyOn(mockEmailService, 'sendVerificationCode') await service.sendEmailCode('test@example.com') @@ -119,7 +119,7 @@ describe('UserService', () => { describe('loginByPassword', () => { it('should throw for nonexistent user', async () => { - mockUserModel.findOne.mockReturnValueOnce({ exec: jest.fn().mockResolvedValue(null) }) + mockUserModel.findOne.mockReturnValueOnce({ exec: jest.fn().mockResolvedValue(null), select: jest.fn().mockReturnThis() }) await expect(service.loginByPassword('test@example.com', 'pass')) .rejects.toThrow(HttpException) }) diff --git a/backend/src/modules/user/user.service.ts b/backend/src/modules/user/user.service.ts index d7dc9b9..f70b0f4 100644 --- a/backend/src/modules/user/user.service.ts +++ b/backend/src/modules/user/user.service.ts @@ -108,7 +108,7 @@ export class UserService { emailCodeStore.delete(email) // 按邮箱查找或创建用户 - let user = await this.userModel.findOne({ email }).exec() + let user = await this.userModel.findOne({ email }).select('+password').exec() let isNew = false if (!user) { isNew = true @@ -120,7 +120,7 @@ export class UserService { // 🔐 密码登录 async loginByPassword(email: string, password: string) { - const user = await this.userModel.findOne({ email }).exec() + const user = await this.userModel.findOne({ email }).select('+password').exec() if (!user) throw new HttpException('账号不存在', HttpStatus.NOT_FOUND) if (!user.password) throw new HttpException('该账号未设置密码,请使用验证码登录', HttpStatus.UNAUTHORIZED) const match = await bcrypt.compare(password, user.password) @@ -136,7 +136,7 @@ export class UserService { if (!password || password.length < 6) { throw new HttpException('密码至少6位', HttpStatus.BAD_REQUEST) } - const existing = await this.userModel.findOne({ email }).exec() + const existing = await this.userModel.findOne({ email }).select('+password').exec() if (existing) { if (existing.password) { throw new HttpException('该邮箱已注册,请直接登录', HttpStatus.CONFLICT) @@ -192,7 +192,7 @@ export class UserService { } private generateAuthResponse(user: UserDocument) { - const payload = { userId: user._id.toString(), phone: user.phone || '' } + const payload = { userId: user._id.toString(), phone: user.phone || '', role: user.role || 'user' } return { token: this.jwtService.sign(payload), user: this.safeUser(user), diff --git a/backend/tsconfig.build.json b/backend/tsconfig.build.json deleted file mode 100644 index 18878db..0000000 --- a/backend/tsconfig.build.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "extends": "./tsconfig.json", - "compilerOptions": { - "rootDir": "src" - }, - "include": ["src/**/*"], - "exclude": ["node_modules", "dist", "test", "**/*spec.ts", "**/*.spec.ts"] -} diff --git a/backend/tsconfig.json b/backend/tsconfig.json index b87d47f..03e3757 100644 --- a/backend/tsconfig.json +++ b/backend/tsconfig.json @@ -11,7 +11,7 @@ "sourceMap": true, "outDir": "./dist", "baseUrl": "./", - "incremental": true, + "incremental": false, "skipLibCheck": true, "strictNullChecks": true, "noImplicitAny": false, diff --git a/zhiyin-app/src/pages/login/login.vue b/zhiyin-app/src/pages/login/login.vue index c6848c8..ea8eeb3 100644 --- a/zhiyin-app/src/pages/login/login.vue +++ b/zhiyin-app/src/pages/login/login.vue @@ -182,17 +182,14 @@ const sendEmailCode = () => { if (cooldown.value > 0) { showToast('请稍后再试'); return } const re = /^[^\s@]+@[^\s@]+\.[^\s@]+$/ if (!re.test(email.value)) { showToast('请输入正确的邮箱'); return } - console.log('[sendEmailCode] 发送中,email:', email.value) uni.request({ url: api('/user/send-email-code'), method: 'POST', header: { 'Content-Type': 'application/json' }, data: { email: email.value }, success: (res) => { - console.log('[sendEmailCode] success res:', JSON.stringify(res)) if (res.statusCode === 200) { emailSent.value = true - console.log('[sendEmailCode] emailSent 设为 true') showToast('验证码已发送', 'success') startCooldown() } else { diff --git a/zhiyin-app/src/pages/resume/resume.vue b/zhiyin-app/src/pages/resume/resume.vue index 5e85666..f109e3e 100644 --- a/zhiyin-app/src/pages/resume/resume.vue +++ b/zhiyin-app/src/pages/resume/resume.vue @@ -252,7 +252,7 @@ const uploadMpFile = async (filePath, name) => { fileName.value = name uploading.value = true try { - const res = await uni.uploadFile({ url: api('/upload'), filePath, name: 'file' }) + const res = await uni.uploadFile({ url: api('/upload'), filePath, name: 'file', header: { 'Authorization': `Bearer ${token()}` } }) const data = JSON.parse(res.data) if (res.statusCode === 200) { resumeText.value = data.text @@ -271,7 +271,7 @@ const onFileSelected = async (e) => { try { const formData = new FormData() formData.append('file', file) - const res = await fetch(api('/upload'), { method: 'POST', body: formData }) + const res = await fetch(api('/upload'), { method: 'POST', headers: { 'Authorization': `Bearer ${token()}` }, body: formData }) const data = await res.json() if (res.ok) { resumeText.value = data.text