v4.2 冲刺版+每日推送+支付修复+全量代码评审

## 新增功能
- 冲刺版 ¥49.9/月:完整支付→激活→权益扣减链路
- 每日一题定时推送(@nestjs/schedule,早8点微信订阅消息)
- miniprogram-ci 编译上传脚本(scripts/upload-mp.js)

## Bug修复
- 套餐值统一:vip→growth/sprint(interview轮次限制、analyze次数检查)
- member/pay 移除开发绕过:改为订单校验后激活
- progress→report 参数名不匹配:id→interviewId
- result.vue resume.create() 参数传错(对象→独立参数)
- resume.vue analyze请求缺少Authorization header
- bank.vue contribution请求缺少Authorization header
- member.vue startPay() 缺少try/catch导致网络错误崩溃
- login.vue 调试面板 v-if="true" 生产泄漏

## 配置
- 微信支付生产证书就位(商户号1113760598)
- .env 清理冗余文件(删除.example/.production)
- WX_NOTIFY_URL 更新为 zhiyinwx.yzrcloud.cn

## 文档
- PROJECT-STATUS.md v4.1→v4.2,状态全面更新
- DEPLOYMENT.md 新增小程序编译上传章节、清理检查清单
This commit is contained in:
yuzhiran
2026-06-09 20:03:05 +08:00
parent 37cfdfe93c
commit 9276ab9028
44 changed files with 15205 additions and 2062 deletions
-17
View File
@@ -1,17 +0,0 @@
# 职引后端环境变量配置
# 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
-31
View File
@@ -1,31 +0,0 @@
# 生产环境配置
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
+46
View File
@@ -16,6 +16,7 @@
"@nestjs/mongoose": "^10.0.2", "@nestjs/mongoose": "^10.0.2",
"@nestjs/passport": "^11.0.5", "@nestjs/passport": "^11.0.5",
"@nestjs/platform-express": "^10.3.0", "@nestjs/platform-express": "^10.3.0",
"@nestjs/schedule": "^6.1.3",
"@nestjs/serve-static": "^4.0.2", "@nestjs/serve-static": "^4.0.2",
"@nestjs/throttler": "^6.5.0", "@nestjs/throttler": "^6.5.0",
"axios": "^1.16.1", "axios": "^1.16.1",
@@ -2074,6 +2075,19 @@
"node": ">= 10.16.0" "node": ">= 10.16.0"
} }
}, },
"node_modules/@nestjs/schedule": {
"version": "6.1.3",
"resolved": "https://registry.npmmirror.com/@nestjs/schedule/-/schedule-6.1.3.tgz",
"integrity": "sha512-RflMFOpR16Dwd1jAUbeB4mfGTCh65fvEdL4mSjQPJChpkRGRjIXjb+6YQcK2faQrVT60c9DmLmoVR7/ONCtuYQ==",
"license": "MIT",
"dependencies": {
"cron": "4.4.0"
},
"peerDependencies": {
"@nestjs/common": "^10.0.0 || ^11.0.0",
"@nestjs/core": "^10.0.0 || ^11.0.0"
}
},
"node_modules/@nestjs/schematics": { "node_modules/@nestjs/schematics": {
"version": "10.2.3", "version": "10.2.3",
"resolved": "https://registry.npmjs.org/@nestjs/schematics/-/schematics-10.2.3.tgz", "resolved": "https://registry.npmjs.org/@nestjs/schematics/-/schematics-10.2.3.tgz",
@@ -2464,6 +2478,12 @@
"@types/node": "*" "@types/node": "*"
} }
}, },
"node_modules/@types/luxon": {
"version": "3.7.1",
"resolved": "https://registry.npmmirror.com/@types/luxon/-/luxon-3.7.1.tgz",
"integrity": "sha512-H3iskjFIAn5SlJU7OuxUmTEpebK6TKB8rxZShDslBMZJ5u9S//KM1sbdAisiSrqwLQncVjnpi2OK2J51h+4lsg==",
"license": "MIT"
},
"node_modules/@types/methods": { "node_modules/@types/methods": {
"version": "1.1.4", "version": "1.1.4",
"resolved": "https://registry.npmjs.org/@types/methods/-/methods-1.1.4.tgz", "resolved": "https://registry.npmjs.org/@types/methods/-/methods-1.1.4.tgz",
@@ -4241,6 +4261,23 @@
} }
} }
}, },
"node_modules/cron": {
"version": "4.4.0",
"resolved": "https://registry.npmmirror.com/cron/-/cron-4.4.0.tgz",
"integrity": "sha512-fkdfq+b+AHI4cKdhZlppHveI/mgz2qpiYxcm+t5E5TsxX7QrLS1VE0+7GENEk9z0EeGPcpSciGv6ez24duWhwQ==",
"license": "MIT",
"dependencies": {
"@types/luxon": "~3.7.0",
"luxon": "~3.7.0"
},
"engines": {
"node": ">=18.x"
},
"funding": {
"type": "ko-fi",
"url": "https://ko-fi.com/intcreator"
}
},
"node_modules/cross-spawn": { "node_modules/cross-spawn": {
"version": "7.0.6", "version": "7.0.6",
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
@@ -7017,6 +7054,15 @@
"dev": true, "dev": true,
"license": "ISC" "license": "ISC"
}, },
"node_modules/luxon": {
"version": "3.7.2",
"resolved": "https://registry.npmmirror.com/luxon/-/luxon-3.7.2.tgz",
"integrity": "sha512-vtEhXh/gNjI9Yg1u4jX/0YVPMvxzHuGgCm6tC5kZyb08yjGWGnqAjGJvcXbqQR2P3MyMEFnRbpcdFS6PBcLqew==",
"license": "MIT",
"engines": {
"node": ">=12"
}
},
"node_modules/magic-string": { "node_modules/magic-string": {
"version": "0.30.8", "version": "0.30.8",
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.8.tgz", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.8.tgz",
+1
View File
@@ -42,6 +42,7 @@
"@nestjs/mongoose": "^10.0.2", "@nestjs/mongoose": "^10.0.2",
"@nestjs/passport": "^11.0.5", "@nestjs/passport": "^11.0.5",
"@nestjs/platform-express": "^10.3.0", "@nestjs/platform-express": "^10.3.0",
"@nestjs/schedule": "^6.1.3",
"@nestjs/serve-static": "^4.0.2", "@nestjs/serve-static": "^4.0.2",
"@nestjs/throttler": "^6.5.0", "@nestjs/throttler": "^6.5.0",
"axios": "^1.16.1", "axios": "^1.16.1",
+4
View File
@@ -3,6 +3,7 @@ import { MongooseModule } from '@nestjs/mongoose'
import { JwtModule } from '@nestjs/jwt' import { JwtModule } from '@nestjs/jwt'
import { PassportModule } from '@nestjs/passport' import { PassportModule } from '@nestjs/passport'
import { ThrottlerModule } from '@nestjs/throttler' import { ThrottlerModule } from '@nestjs/throttler'
import { ScheduleModule as NestScheduleModule } from '@nestjs/schedule'
import { APP_GUARD } from '@nestjs/core' import { APP_GUARD } from '@nestjs/core'
import { JwtStrategy } from './common/strategies/jwt.strategy' import { JwtStrategy } from './common/strategies/jwt.strategy'
@@ -21,6 +22,7 @@ import { AnalyzeModule } from './modules/analyze/analyze.module'
import { ProgressModule } from './modules/progress/progress.module' import { ProgressModule } from './modules/progress/progress.module'
import { ContributionModule } from './modules/contribution/contribution.module' import { ContributionModule } from './modules/contribution/contribution.module'
import { DailyQuestionModule } from './modules/daily-question/daily-question.module' import { DailyQuestionModule } from './modules/daily-question/daily-question.module'
import { ScheduleModule } from './modules/schedule/schedule.module'
const MONGODB_URI = process.env.MONGODB_URI || 'mongodb://localhost:27017/zhiyin' const MONGODB_URI = process.env.MONGODB_URI || 'mongodb://localhost:27017/zhiyin'
@@ -36,6 +38,7 @@ const MONGODB_URI = process.env.MONGODB_URI || 'mongodb://localhost:27017/zhiyin
ttl: 60000, ttl: 60000,
limit: 10, limit: 10,
}]), }]),
NestScheduleModule.forRoot(),
UserModule, UserModule,
AiModule, AiModule,
InterviewModule, InterviewModule,
@@ -50,6 +53,7 @@ const MONGODB_URI = process.env.MONGODB_URI || 'mongodb://localhost:27017/zhiyin
ProgressModule, ProgressModule,
ContributionModule, ContributionModule,
DailyQuestionModule, DailyQuestionModule,
ScheduleModule,
], ],
providers: [ providers: [
JwtStrategy, JwtStrategy,
+72
View File
@@ -0,0 +1,72 @@
/**
* 面试回答中的语气词/填充词分析
* 检测用户回答中的语气词密度、语速估算等
*/
const CHINESE_FILLER_WORDS = [
'嗯', '啊', '呃', '哦', '那个', '这个', '然后', '就是',
'对吧', '所以说', '反正', '其实', '就是说', '那', '那么',
'还有一个', '另外', '基本上', '大概', '可能', '应该',
'的话', '的时候', '一种', '一个', '一种', '可以说',
]
const ENGLISH_FILLER_WORDS = [
'um', 'uh', 'er', 'ah', 'like', 'you know', 'actually',
'basically', 'literally', 'honestly', 'sort of', 'kind of',
'i mean', 'you see', 'well', 'so', 'anyway',
]
function countOccurrences(text: string, words: string[]): { word: string; count: number }[] {
const result: { word: string; count: number }[] = []
const lowerText = text.toLowerCase()
for (const word of words) {
const escaped = word.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
const regex = new RegExp(escaped, 'g')
const matches = lowerText.match(regex)
if (matches && matches.length > 0) {
result.push({ word, count: matches.length })
}
}
return result.sort((a, b) => b.count - a.count)
}
export function analyzeSpeech(text: string) {
const totalChars = text.length
const totalWords = text.split(/\s+/).filter(Boolean).length
const chineseFillers = countOccurrences(text, CHINESE_FILLER_WORDS)
const englishFillers = countOccurrences(text, ENGLISH_FILLER_WORDS)
const allFillers = [...chineseFillers, ...englishFillers]
const totalFillerCount = allFillers.reduce((s, f) => s + f.count, 0)
// 估算语速(中文字符/秒,假设正常说话速度 ~3-4 字/秒)
const estimatedDurationSec = Math.max(totalChars / 3.5, 10)
const speechRate = totalChars / estimatedDurationSec
// 语气词密度
const fillerDensity = totalChars > 0 ? totalFillerCount / totalChars : 0
// 评分:语气词越少越好
let fillerScore = 100
if (fillerDensity > 0.15) fillerScore = 40
else if (fillerDensity > 0.10) fillerScore = 60
else if (fillerDensity > 0.05) fillerScore = 80
// 判断是否过长/过短
let lengthFeedback = ''
if (totalChars < 20) lengthFeedback = '回答过短,建议展开阐述'
else if (totalChars > 500) lengthFeedback = '回答偏长,建议精简重点'
return {
totalChars,
totalWords,
fillerCount: totalFillerCount,
fillerWords: allFillers.slice(0, 5),
fillerDensity: Math.round(fillerDensity * 1000) / 10,
fillerScore,
estimatedDurationSec: Math.round(estimatedDurationSec),
speechRate: Math.round(speechRate * 10) / 10,
lengthFeedback,
topFiller: allFillers[0]?.word || null,
}
}
+42 -1
View File
@@ -1,4 +1,38 @@
import 'dotenv/config' import 'dotenv/config'
// Polyfill DOMMatrix for pdf-parse (pdf.js requires browser API)
if (typeof globalThis.DOMMatrix === 'undefined') {
class DOMMatrixPolyfill {
a = 1; b = 0; c = 0; d = 1; e = 0; f = 0
m11 = 1; m12 = 0; m13 = 0; m14 = 0
m21 = 0; m22 = 1; m23 = 0; m24 = 0
m31 = 0; m32 = 0; m33 = 1; m34 = 0
m41 = 0; m42 = 0; m43 = 0; m44 = 1
is2D = true; isIdentity = true
constructor(init?: string | number[]) {
if (typeof init === 'string') {
const m = init.match(/matrix\(([^)]+)\)/)
if (m) {
const v = m[1].split(',').map(Number)
if (v.length >= 6) { this.a = v[0]; this.b = v[1]; this.c = v[2]; this.d = v[3]; this.e = v[4]; this.f = v[5] }
}
}
}
multiply(m: DOMMatrixPolyfill) { return this }
translate(x: number, y: number) { return this }
scale(s: number) { return this }
rotate(r: number) { return this }
toString() { return `matrix(${this.a}, ${this.b}, ${this.c}, ${this.d}, ${this.e}, ${this.f})` }
toJSON() { return { a: this.a, b: this.b, c: this.c, d: this.d, e: this.e, f: this.f } }
}
// @ts-ignore
globalThis.DOMMatrix = DOMMatrixPolyfill
// @ts-ignore
globalThis.DOMMatrixReadOnly = DOMMatrixPolyfill
// @ts-ignore
globalThis.DOMPoint = class { x = 0; y = 0; z = 0; w = 1 }
}
import { NestFactory } from '@nestjs/core' import { NestFactory } from '@nestjs/core'
import { ValidationPipe } from '@nestjs/common' import { ValidationPipe } from '@nestjs/common'
import { AppModule } from './app.module' import { AppModule } from './app.module'
@@ -8,8 +42,15 @@ async function bootstrap() {
const app = await NestFactory.create(AppModule) const app = await NestFactory.create(AppModule)
app.setGlobalPrefix('api') app.setGlobalPrefix('api')
const allowedOrigins = process.env.CORS_ORIGINS?.split(',') || ['http://localhost:8085', 'http://localhost:3006']
app.enableCors({ app.enableCors({
origin: '*', origin: (origin, callback) => {
if (!origin || allowedOrigins.includes(origin) || process.env.NODE_ENV !== 'production') {
callback(null, true)
} else {
callback(new Error('Not allowed by CORS'))
}
},
methods: 'GET,HEAD,PUT,PATCH,POST,DELETE', methods: 'GET,HEAD,PUT,PATCH,POST,DELETE',
credentials: true, credentials: true,
}) })
+21 -16
View File
@@ -1,7 +1,6 @@
import { Controller, Get, Post, Body, Query, HttpException, HttpStatus, UseGuards } from '@nestjs/common' import { Controller, Get, Post, Body, Query, HttpException, HttpStatus, UseGuards } from '@nestjs/common'
import { InjectModel } from '@nestjs/mongoose' import { InjectModel } from '@nestjs/mongoose'
import { Model } from 'mongoose' import { Model } from 'mongoose'
import { Public } from '../../common/decorators/public.decorator'
import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard' import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard'
import { CurrentUser } from '../../common/decorators/current-user.decorator' import { CurrentUser } from '../../common/decorators/current-user.decorator'
import { User, UserDocument } from '../user/user.schema' import { User, UserDocument } from '../user/user.schema'
@@ -29,19 +28,21 @@ export class AdminController {
return { isAdmin: user?.role === 'admin' } return { isAdmin: user?.role === 'admin' }
} }
@Public() @UseGuards(JwtAuthGuard)
@Post('verify') @Post('verify')
async verify(@Body('adminId') adminId: string) { async verify(@CurrentUser('userId') userId: string) {
const user = await this.userModel.findById(adminId).exec() const user = await this.userModel.findById(userId).exec()
if (!user || user.role !== 'admin') { if (!user || user.role !== 'admin') {
throw new HttpException('无权限访问', HttpStatus.FORBIDDEN) throw new HttpException('无权限访问', HttpStatus.FORBIDDEN)
} }
return { ok: true, nickname: user.nickname || '管理员' } return { ok: true, nickname: user.nickname || '管理员' }
} }
@Public() @UseGuards(JwtAuthGuard)
@Get('overview') @Get('overview')
async overview() { async overview(@CurrentUser('userId') adminUserId: string) {
const admin = await this.userModel.findById(adminUserId).exec()
if (admin?.role !== 'admin') throw new HttpException('无权限', HttpStatus.FORBIDDEN)
const [userCount, interviewCount, todayUsers, todayInterviews] = await Promise.all([ const [userCount, interviewCount, todayUsers, todayInterviews] = await Promise.all([
this.userModel.countDocuments().exec(), this.userModel.countDocuments().exec(),
this.interviewModel.countDocuments().exec(), this.interviewModel.countDocuments().exec(),
@@ -51,9 +52,11 @@ export class AdminController {
return { userCount, interviewCount, todayUsers, todayInterviews } return { userCount, interviewCount, todayUsers, todayInterviews }
} }
@Public() @UseGuards(JwtAuthGuard)
@Get('users') @Get('users')
async getUsers(@Query('keyword') keyword: string, @Query('page') page = '1', @Query('limit') limit = '20') { 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)
const filter: any = {} const filter: any = {}
if (keyword) filter.$or = [ if (keyword) filter.$or = [
{ phone: { $regex: keyword, $options: 'i' } }, { phone: { $regex: keyword, $options: 'i' } },
@@ -67,9 +70,11 @@ export class AdminController {
return { users, total, page: +page } return { users, total, page: +page }
} }
@Public() @UseGuards(JwtAuthGuard)
@Get('interviews') @Get('interviews')
async getInterviews(@Query('page') page = '1', @Query('limit') limit = '20') { 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)
const skip = (Math.max(1, +page) - 1) * +limit const skip = (Math.max(1, +page) - 1) * +limit
const [interviews, total] = await Promise.all([ const [interviews, total] = await Promise.all([
this.interviewModel.find().sort({ createdAt: -1 }).skip(skip).limit(+limit).populate('userId', 'phone nickname').lean().exec(), this.interviewModel.find().sort({ createdAt: -1 }).skip(skip).limit(+limit).populate('userId', 'phone nickname').lean().exec(),
@@ -86,12 +91,12 @@ export class AdminController {
const user = await this.userModel.findById(targetUserId).exec() const user = await this.userModel.findById(targetUserId).exec()
if (!user) throw new HttpException('用户不存在', HttpStatus.NOT_FOUND) if (!user) throw new HttpException('用户不存在', HttpStatus.NOT_FOUND)
const expireAt = new Date() const expireAt = new Date()
expireAt.setDate(expireAt.getDate() + 30) expireAt.setDate(expireAt.getDate() + VIP_DURATION_DAYS)
user.plan = 'vip' user.plan = 'growth'
user.vipExpireAt = expireAt user.vipExpireAt = expireAt
user.remaining = 999 user.remaining = 999
await user.save() await user.save()
return { success: true, plan: 'vip', expireAt } return { success: true, plan: 'growth', expireAt }
} }
@UseGuards(JwtAuthGuard) @UseGuards(JwtAuthGuard)
@@ -144,14 +149,14 @@ export class AdminController {
if (!order) throw new HttpException('订单不存在', HttpStatus.NOT_FOUND) if (!order) throw new HttpException('订单不存在', HttpStatus.NOT_FOUND)
if (tradeState === 'SUCCESS' && order.status === 'pending') { if (tradeState === 'SUCCESS' && order.status === 'pending') {
order.status = 'success' order.status = 'success'
order.wxTransactionId = wxResult.transaction_id order.wxTransactionId = wxResult?.transaction_id || ''
order.paidAt = new Date() order.paidAt = new Date()
await order.save() await order.save()
const user = await this.userModel.findById(order.userId).exec() const user = await this.userModel.findById(order.userId).exec()
if (user && user.plan !== 'vip') { if (user && user.plan === 'free') {
const expireAt = new Date() const expireAt = new Date()
expireAt.setDate(expireAt.getDate() + VIP_DURATION_DAYS) expireAt.setDate(expireAt.getDate() + VIP_DURATION_DAYS)
user.plan = 'vip' user.plan = 'growth'
user.vipExpireAt = expireAt user.vipExpireAt = expireAt
user.remaining = 999 user.remaining = 999
await user.save() await user.save()
@@ -1,14 +1,32 @@
import { Controller, Post, Body, HttpException, HttpStatus, UseGuards } from '@nestjs/common' import { Controller, Post, Body, HttpException, HttpStatus, UseGuards } from '@nestjs/common'
import { InjectModel } from '@nestjs/mongoose'
import { Model } from 'mongoose'
import { AnalyzeService } from './analyze.service' import { AnalyzeService } from './analyze.service'
import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard' import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard'
import { CurrentUser } from '../../common/decorators/current-user.decorator' import { CurrentUser } from '../../common/decorators/current-user.decorator'
import { UserService } from '../user/user.service' import { UserService } from '../user/user.service'
import { Progress, ProgressDocument } from '../schemas/progress.schema'
// 各岗位的四维基准分(基于校招平均水平)
const POSITION_BENCHMARKS: Record<string, { logic: number; expression: number; professionalism: number; stability: number }> = {
'前端工程师': { logic: 75, expression: 70, professionalism: 72, stability: 68 },
'后端工程师': { logic: 80, expression: 65, professionalism: 78, stability: 65 },
'AI算法工程师': { logic: 85, expression: 60, professionalism: 85, stability: 60 },
'产品经理': { logic: 72, expression: 82, professionalism: 70, stability: 75 },
'数据分析师': { logic: 78, expression: 72, professionalism: 75, stability: 70 },
'UI/UX设计师': { logic: 65, expression: 80, professionalism: 75, stability: 72 },
'运营': { logic: 68, expression: 82, professionalism: 65, stability: 78 },
'测试工程师': { logic: 72, expression: 65, professionalism: 75, stability: 70 },
}
const DEFAULT_BENCHMARK = { logic: 70, expression: 70, professionalism: 70, stability: 70 }
@Controller('analyze') @Controller('analyze')
export class AnalyzeController { export class AnalyzeController {
constructor( constructor(
private analyzeService: AnalyzeService, private analyzeService: AnalyzeService,
private userService: UserService, private userService: UserService,
@InjectModel(Progress.name) private progressModel: Model<ProgressDocument>,
) {} ) {}
@UseGuards(JwtAuthGuard) @UseGuards(JwtAuthGuard)
@@ -25,14 +43,82 @@ export class AnalyzeController {
return this.analyzeService.optimize(content, direction) return this.analyzeService.optimize(content, direction)
} }
/** 技能缺口分析:将用户四维分数与目标岗位基准对比 */
@UseGuards(JwtAuthGuard)
@Post('skills-gap')
async skillsGap(@CurrentUser('userId') userId: string, @Body('targetPosition') targetPosition?: string) {
const progress = await this.progressModel.findOne({ userId }).exec()
if (!progress || progress.completedInterviews === 0) {
throw new HttpException('暂无面试数据,请先完成至少一次模拟面试', HttpStatus.BAD_REQUEST)
}
const userDims = {
logic: progress.avgLogic || 0,
expression: progress.avgExpression || 0,
professionalism: progress.avgProfessionalism || 0,
stability: progress.avgStability || 0,
}
// 使用用户最近一次面试的岗位,或传入的目标岗位
const lastPosition = targetPosition || (progress.recentScores.length > 0 ? progress.recentScores[progress.recentScores.length - 1].position : '')
const benchmark = POSITION_BENCHMARKS[lastPosition] || DEFAULT_BENCHMARK
const gaps: Array<{ dimension: string; currentScore: number; targetScore: number; gap: number; level: string }> = []
const dimLabels: Record<string, string> = {
logic: '逻辑思维',
expression: '表达能力',
professionalism: '专业度',
stability: '情绪稳定性',
}
for (const [key, label] of Object.entries(dimLabels)) {
const score = userDims[key as keyof typeof userDims]
const target = benchmark[key as keyof typeof benchmark]
const gap = target - score
if (gap > 0) {
gaps.push({
dimension: label,
currentScore: score,
targetScore: target,
gap: Math.round(gap),
level: gap > 15 ? '严重不足' : gap > 5 ? '需提升' : '接近基准',
})
}
}
// 生成学习建议
const suggestions = gaps.map(g => {
const tips: Record<string, string> = {
'逻辑思维': '多用 STAR 法则组织回答,练习"结论先行+论据支撑"结构',
'表达能力': '录音回听自己的回答,控制语速,减少语气词,精炼表达',
'专业度': '补充岗位相关基础知识,阅读面经中的专业问题',
'情绪稳定性': '进行更多模拟面试练习,适应面试压力环境',
}
return { dimension: g.dimension, tip: tips[g.dimension] || '持续练习提升' }
})
const totalGap = gaps.reduce((s, g) => s + g.gap, 0)
return {
dimensions: userDims,
benchmark,
gaps,
suggestions,
totalGap,
assessment: totalGap > 30 ? '整体与目标岗位存在差距,建议针对性提升' :
totalGap > 10 ? '接近目标水平,针对薄弱项提升即可' :
'已达到或超过目标岗位基准,继续保持',
targetPosition: lastPosition,
}
}
private async checkAnalyzeLimit(userId: string) { private async checkAnalyzeLimit(userId: string) {
const user = await this.userService.getModel().findById(userId).exec() const user = await this.userService.getModel().findById(userId).exec()
if (!user) throw new HttpException('用户不存在', HttpStatus.NOT_FOUND) if (!user) throw new HttpException('用户不存在', HttpStatus.NOT_FOUND)
if (user.plan === 'vip') return // VIP 不限次 if (user.plan !== 'free') return
if (user.remaining <= 0) { if (user.remaining <= 0) {
throw new HttpException('免费版每日次数已用完,升级会员后不限次使用', HttpStatus.FORBIDDEN) throw new HttpException('免费版每日次数已用完,升级会员后不限次使用', HttpStatus.FORBIDDEN)
} }
// 扣减一次
user.remaining -= 1 user.remaining -= 1
await user.save() await user.save()
} }
@@ -1,10 +1,15 @@
import { Module } from '@nestjs/common' import { Module } from '@nestjs/common'
import { MongooseModule } from '@nestjs/mongoose'
import { AnalyzeController } from './analyze.controller' import { AnalyzeController } from './analyze.controller'
import { AnalyzeService } from './analyze.service' import { AnalyzeService } from './analyze.service'
import { UserModule } from '../user/user.module' import { UserModule } from '../user/user.module'
import { Progress, ProgressSchema } from '../schemas/progress.schema'
@Module({ @Module({
imports: [UserModule], imports: [
UserModule,
MongooseModule.forFeature([{ name: Progress.name, schema: ProgressSchema }]),
],
controllers: [AnalyzeController], controllers: [AnalyzeController],
providers: [AnalyzeService], providers: [AnalyzeService],
}) })
@@ -25,6 +25,15 @@ export class Interview {
@Prop({ default: '' }) @Prop({ default: '' })
summary: string summary: string
@Prop({ type: [{ word: String, count: Number }], default: [] })
fillerWords: { word: string; count: number }[]
@Prop({ default: 0 })
fillerScore: number
@Prop({ default: 0 })
fillerDensity: number
} }
export const InterviewSchema = SchemaFactory.createForClass(Interview) export const InterviewSchema = SchemaFactory.createForClass(Interview)
@@ -1,10 +1,11 @@
import { Injectable, HttpException, HttpStatus, forwardRef, Inject } from '@nestjs/common' import { Injectable, HttpException, HttpStatus } from '@nestjs/common'
import { InjectModel } from '@nestjs/mongoose' import { InjectModel } from '@nestjs/mongoose'
import { Model } from 'mongoose' import { Model } from 'mongoose'
import { Interview, InterviewDocument } from './interview.schema' import { Interview, InterviewDocument } from './interview.schema'
import { Progress, ProgressDocument } from '../schemas/progress.schema' import { Progress, ProgressDocument } from '../schemas/progress.schema'
import { AiService } from '../ai/ai.service' import { AiService } from '../ai/ai.service'
import { UserService } from '../user/user.service' import { UserService } from '../user/user.service'
import { analyzeSpeech } from '../../common/utils/filler-words'
@Injectable() @Injectable()
export class InterviewService { export class InterviewService {
@@ -47,10 +48,10 @@ export class InterviewService {
// 检查轮次限制 // 检查轮次限制
const user = await this.userService.getModel().findById(userId).exec() const user = await this.userService.getModel().findById(userId).exec()
const maxRounds = user?.plan === 'vip' ? 10 : 5 const maxRounds = user?.plan !== 'free' ? 10 : 5
if (interview.questionCount >= maxRounds) { if (interview.questionCount >= maxRounds) {
throw new HttpException( throw new HttpException(
user?.plan === 'vip' ? '已达到每场面试最大轮次(10轮)' : '免费版每场最多5轮,升级会员可享10轮', user?.plan !== 'free' ? '已达到每场面试最大轮次(10轮)' : '免费版每场最多5轮,升级会员可享10轮',
HttpStatus.FORBIDDEN HttpStatus.FORBIDDEN
) )
} }
@@ -59,6 +60,12 @@ export class InterviewService {
interview.messages.push({ role: 'user', content: answer }) interview.messages.push({ role: 'user', content: answer })
interview.questionCount += 1 interview.questionCount += 1
// Analyze filler words in user's answer
const speechAnalysis = analyzeSpeech(answer)
interview.fillerWords = speechAnalysis.fillerWords.map(f => ({ word: f.word, count: f.count }))
interview.fillerScore = speechAnalysis.fillerScore
interview.fillerDensity = speechAnalysis.fillerDensity
// AI evaluates answer and generates next question // AI evaluates answer and generates next question
const conversationHistory = interview.messages const conversationHistory = interview.messages
.slice(-6) .slice(-6)
@@ -230,7 +237,21 @@ ${fullConversation}
async getDetail(interviewId: string, userId: string) { async getDetail(interviewId: string, userId: string) {
const interview = await this.interviewModel.findOne({ _id: interviewId, userId }).exec() const interview = await this.interviewModel.findOne({ _id: interviewId, userId }).exec()
if (!interview) throw new HttpException('面试不存在', HttpStatus.NOT_FOUND) if (!interview) throw new HttpException('面试不存在', HttpStatus.NOT_FOUND)
return interview
// Compute aggregate speech analysis from user messages
const userAnswers = interview.messages
.filter(m => m.role === 'user')
.map(m => m.content)
.join('\n')
const speechAnalysis = userAnswers ? analyzeSpeech(userAnswers) : null
return {
...interview.toObject(),
fillerWords: speechAnalysis?.fillerWords || interview.fillerWords,
fillerScore: speechAnalysis?.fillerScore || interview.fillerScore,
fillerDensity: speechAnalysis?.fillerDensity || interview.fillerDensity,
speechAnalysis,
}
} }
async getList(userId: string) { async getList(userId: string) {
+51 -54
View File
@@ -1,12 +1,14 @@
import { Controller, Post, Get, Body, HttpException, HttpStatus, UseGuards } from '@nestjs/common' import { Controller, Post, Get, Body, HttpException, HttpStatus, UseGuards, Logger } from '@nestjs/common'
import { InjectModel } from '@nestjs/mongoose' import { InjectModel } from '@nestjs/mongoose'
import { Model } from 'mongoose' import { Model } from 'mongoose'
import { User, UserDocument } from '../user/user.schema' import { User, UserDocument } from '../user/user.schema'
import { PaymentOrder, PaymentOrderDocument } from '../payment/payment-order.schema'
import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard' import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard'
import { CurrentUser } from '../../common/decorators/current-user.decorator' import { CurrentUser } from '../../common/decorators/current-user.decorator'
import { Public } from '../../common/decorators/public.decorator' import { Public } from '../../common/decorators/public.decorator'
const GROWTH_PRICE = 1990 const GROWTH_PRICE = 1990
const SPRINT_PRICE = 4990
const DURATION_DAYS = 30 const DURATION_DAYS = 30
const FREE_DAILY_LIMIT = 2 const FREE_DAILY_LIMIT = 2
@@ -20,30 +22,23 @@ interface PlanConfig {
const PLANS: Record<string, PlanConfig> = { const PLANS: Record<string, PlanConfig> = {
free: { free: {
id: 'free', id: 'free', name: '免费版', price: 0, dailyLimit: FREE_DAILY_LIMIT,
name: '免费版',
price: 0,
dailyLimit: FREE_DAILY_LIMIT,
features: [ features: [
'每日 2 次 AI 模拟面试', '每日 2 次 AI 模拟面试', '基础面试报告', '通用题库随机出题', '简历诊断(限 3 次)',
'基础面试报告',
'通用题库随机出题',
'简历诊断(限 3 次)',
], ],
}, },
growth: { growth: {
id: 'growth', id: 'growth', name: '成长版', price: GROWTH_PRICE, dailyLimit: 999,
name: '成长版',
price: GROWTH_PRICE,
dailyLimit: 999,
features: [ features: [
'免费版全部权益', '免费版全部权益', '无限面试次数', '详细面试报告(四维评分)',
'无限面试次数', '进步轨迹雷达图 + 打卡', '每日一题推送', '参考回答思路', '公司真题库',
'详细面试报告(四维评分)', ],
'进步轨迹雷达图 + 打卡', },
'每日一题推送', sprint: {
'参考回答思路', id: 'sprint', name: '冲刺版', price: SPRINT_PRICE, dailyLimit: 999,
'公司真题库', features: [
'成长版全部权益', 'AI 语音分析(语气词/语速检测)', '技能缺口分析报告',
'学习路径推荐', '真人导师 1v1 点评(每月 1 次)', '简历精修(每月 1 次)', '内推优先',
], ],
}, },
} }
@@ -51,30 +46,25 @@ const PLANS: Record<string, PlanConfig> = {
@Controller('member') @Controller('member')
@UseGuards(JwtAuthGuard) @UseGuards(JwtAuthGuard)
export class MemberController { export class MemberController {
private readonly logger = new Logger(MemberController.name)
constructor( constructor(
@InjectModel(User.name) private userModel: Model<UserDocument>, @InjectModel(User.name) private userModel: Model<UserDocument>,
@InjectModel(PaymentOrder.name) private orderModel: Model<PaymentOrderDocument>,
) {} ) {}
// 公开的套餐配置(给前端会员页和限制拦截用)
@Public() @Public()
@Get('plans') @Get('plans')
getPlans() { getPlans() {
return { return {
interview: { interview: { dailyFreeLimit: FREE_DAILY_LIMIT, maxRoundsFree: 5, maxRoundsVip: 10 },
dailyFreeLimit: FREE_DAILY_LIMIT,
maxRoundsFree: 5,
maxRoundsVip: 10,
},
diagnosis: { dailyFreeLimit: 2 }, diagnosis: { dailyFreeLimit: 2 },
optimize: { dailyFreeLimit: 2 }, optimize: { dailyFreeLimit: 2 },
price: { monthly: GROWTH_PRICE }, price: { monthly: GROWTH_PRICE, sprint: SPRINT_PRICE },
plans: Object.values(PLANS).map(p => ({ plans: Object.values(PLANS).map(p => ({
id: p.id, id: p.id, name: p.name, price: p.price,
name: p.name,
price: p.price,
priceDisplay: p.price === 0 ? '免费' : `¥${(p.price / 100).toFixed(1)}/月`, priceDisplay: p.price === 0 ? '免费' : `¥${(p.price / 100).toFixed(1)}/月`,
dailyLimit: p.dailyLimit, dailyLimit: p.dailyLimit, features: p.features,
features: p.features,
})), })),
} }
} }
@@ -90,41 +80,48 @@ export class MemberController {
remaining: user.remaining, remaining: user.remaining,
dailyLimit: planConfig.dailyLimit, dailyLimit: planConfig.dailyLimit,
vipExpireAt: user.vipExpireAt, vipExpireAt: user.vipExpireAt,
sprintExpireAt: user.sprintExpireAt,
sprintRemaining: user.sprintRemaining || 0,
isVip: user.plan !== 'free', isVip: user.plan !== 'free',
} }
} }
@Post('create-order') /** 凭订单激活套餐(前端 JSAPI 支付成功后兜底调用) */
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') @Post('pay')
async pay(@CurrentUser('userId') userId: string, @Body('orderId') orderId: string) { async pay(@CurrentUser('userId') userId: string, @Body('outTradeNo') outTradeNo: string) {
const user = await this.userModel.findById(userId).exec() const user = await this.userModel.findById(userId).exec()
if (!user) throw new HttpException('用户不存在', HttpStatus.NOT_FOUND) if (!user) throw new HttpException('用户不存在', HttpStatus.NOT_FOUND)
if (user.plan !== 'free') return { success: true, plan: user.plan, message: '已是会员' }
const order = await this.orderModel.findOne({ outTradeNo, userId }).exec()
if (!order) throw new HttpException('订单不存在', HttpStatus.NOT_FOUND)
if (order.status !== 'success') throw new HttpException('支付未完成', HttpStatus.BAD_REQUEST)
const expireAt = new Date() const expireAt = new Date()
expireAt.setDate(expireAt.getDate() + DURATION_DAYS) expireAt.setDate(expireAt.getDate() + DURATION_DAYS)
if (order.plan === 'sprint') {
user.plan = 'sprint'
user.sprintExpireAt = expireAt
user.sprintRemaining = 10
} else {
user.plan = 'growth' user.plan = 'growth'
user.vipExpireAt = expireAt user.vipExpireAt = expireAt
}
user.remaining = 999 user.remaining = 999
await user.save() await user.save()
return { return { success: true, plan: user.plan, planName: PLANS[user.plan]?.name, expireAt }
success: true,
plan: 'growth',
planName: '成长版',
expireAt,
message: '支付成功!欢迎开通成长版',
} }
/** 扣减冲刺版权益次数 */
@Post('sprint/deduct')
async deductSprint(@CurrentUser('userId') userId: string) {
const user = await this.userModel.findById(userId).exec()
if (!user) throw new HttpException('用户不存在', HttpStatus.NOT_FOUND)
if (user.plan !== 'sprint') throw new HttpException('非冲刺版会员', HttpStatus.FORBIDDEN)
if (user.sprintExpireAt && user.sprintExpireAt < new Date()) throw new HttpException('会员已过期', HttpStatus.FORBIDDEN)
if ((user.sprintRemaining || 0) <= 0) throw new HttpException('剩余次数不足', HttpStatus.FORBIDDEN)
user.sprintRemaining = (user.sprintRemaining || 0) - 1
await user.save()
return { success: true, sprintRemaining: user.sprintRemaining }
} }
} }
+5 -1
View File
@@ -2,9 +2,13 @@ import { Module } from '@nestjs/common'
import { MongooseModule } from '@nestjs/mongoose' import { MongooseModule } from '@nestjs/mongoose'
import { MemberController } from './member.controller' import { MemberController } from './member.controller'
import { User, UserSchema } from '../user/user.schema' import { User, UserSchema } from '../user/user.schema'
import { PaymentOrder, PaymentOrderSchema } from '../payment/payment-order.schema'
@Module({ @Module({
imports: [MongooseModule.forFeature([{ name: User.name, schema: UserSchema }])], imports: [MongooseModule.forFeature([
{ name: User.name, schema: UserSchema },
{ name: PaymentOrder.name, schema: PaymentOrderSchema },
])],
controllers: [MemberController], controllers: [MemberController],
}) })
export class MemberModule {} export class MemberModule {}
@@ -27,6 +27,9 @@ export class PaymentOrder {
@Prop({ default: 'pending' }) @Prop({ default: 'pending' })
status: string status: string
@Prop({ default: 'growth' })
plan: string // growth | sprint
@Prop({ default: 'native' }) @Prop({ default: 'native' })
channel: string // native | jsapi channel: string // native | jsapi
@@ -1,4 +1,4 @@
import { Controller, Post, Get, Query, Body, UseGuards, HttpException, HttpStatus } from '@nestjs/common' import { Controller, Post, Get, Param, Query, Body, UseGuards, HttpException, HttpStatus, Logger, Req } from '@nestjs/common'
import { InjectModel } from '@nestjs/mongoose' import { InjectModel } from '@nestjs/mongoose'
import { Model } from 'mongoose' import { Model } from 'mongoose'
import { User, UserDocument } from '../user/user.schema' import { User, UserDocument } from '../user/user.schema'
@@ -8,11 +8,14 @@ import { CurrentUser } from '../../common/decorators/current-user.decorator'
import { WechatPayService } from './wechat-pay.service' import { WechatPayService } from './wechat-pay.service'
import { Public } from '../../common/decorators/public.decorator' import { Public } from '../../common/decorators/public.decorator'
const VIP_AMOUNT = 1990 // 19.9 元(分) const GROWTH_AMOUNT = 1990 // 19.9 元(分)
const SPRINT_AMOUNT = 4990 // 49.9 元(分)
const VIP_DURATION_DAYS = 30 const VIP_DURATION_DAYS = 30
@Controller('payment') @Controller('payment')
export class PaymentController { export class PaymentController {
private readonly logger = new Logger(PaymentController.name)
constructor( constructor(
@InjectModel(User.name) private userModel: Model<UserDocument>, @InjectModel(User.name) private userModel: Model<UserDocument>,
@InjectModel(PaymentOrder.name) private orderModel: Model<PaymentOrderDocument>, @InjectModel(PaymentOrder.name) private orderModel: Model<PaymentOrderDocument>,
@@ -22,73 +25,68 @@ export class PaymentController {
/** 创建订单(H5Native 扫码支付) */ /** 创建订单(H5Native 扫码支付) */
@UseGuards(JwtAuthGuard) @UseGuards(JwtAuthGuard)
@Post('create') @Post('create')
async create(@CurrentUser('userId') userId: string) { async create(@CurrentUser('userId') userId: string, @Body('plan') plan: string = 'growth') {
if (!['growth', 'sprint'].includes(plan)) throw new HttpException('无效套餐', HttpStatus.BAD_REQUEST)
const user = await this.userModel.findById(userId).exec() const user = await this.userModel.findById(userId).exec()
if (!user) throw new HttpException('用户不存在', HttpStatus.NOT_FOUND) if (!user) throw new HttpException('用户不存在', HttpStatus.NOT_FOUND)
if (user.plan === 'vip') throw new HttpException('已是会员', HttpStatus.BAD_REQUEST) if (user.plan !== 'free') throw new HttpException('已是会员', HttpStatus.BAD_REQUEST)
const outTradeNo = `VIP${Date.now()}${userId.slice(-6)}` const amount = plan === 'sprint' ? SPRINT_AMOUNT : GROWTH_AMOUNT
const result = await this.wechatPay.nativePay('AI磁场月度会员', outTradeNo, VIP_AMOUNT) const title = plan === 'sprint' ? '职引冲刺版月度会员' : '职引成长版月度会员'
const outTradeNo = `${plan === 'sprint' ? 'SPR' : 'VIP'}${Date.now()}${userId.slice(-6)}`
const result = await this.wechatPay.nativePay(title, outTradeNo, amount)
// 保存订单 await this.orderModel.create({ outTradeNo, userId, userPhone: user.phone || '', amount, title, status: 'pending', channel: 'native', plan })
await this.orderModel.create({
outTradeNo,
userId,
userPhone: user.phone || '',
amount: VIP_AMOUNT,
title: 'AI磁场月度会员',
status: 'pending',
channel: 'native',
})
return { return { outTradeNo, codeUrl: result.codeUrl, amount, title }
outTradeNo,
codeUrl: result.codeUrl,
amount: VIP_AMOUNT,
title: 'AI磁场月度会员',
}
} }
/** JSAPI 支付(微信小程序) */ /** JSAPI 支付(微信小程序) */
@UseGuards(JwtAuthGuard) @UseGuards(JwtAuthGuard)
@Post('jsapi') @Post('jsapi')
async jsapi(@CurrentUser('userId') userId: string, @Body('openid') openid: string) { async jsapi(@CurrentUser('userId') userId: string, @Body('plan') plan: string = 'growth') {
if (!['growth', 'sprint'].includes(plan)) throw new HttpException('无效套餐', HttpStatus.BAD_REQUEST)
const user = await this.userModel.findById(userId).exec() const user = await this.userModel.findById(userId).exec()
if (!user) throw new HttpException('用户不存在', HttpStatus.NOT_FOUND) if (!user) throw new HttpException('用户不存在', HttpStatus.NOT_FOUND)
if (!openid) throw new HttpException('缺少 openid', HttpStatus.BAD_REQUEST) if (user.plan !== 'free') throw new HttpException('已是会员', HttpStatus.BAD_REQUEST)
if (user.plan === 'vip') throw new HttpException('已是会员', HttpStatus.BAD_REQUEST) const openid = user.wxOpenid
if (!openid) throw new HttpException('未绑定微信', HttpStatus.BAD_REQUEST)
const outTradeNo = `VIP${Date.now()}${userId.slice(-6)}` const amount = plan === 'sprint' ? SPRINT_AMOUNT : GROWTH_AMOUNT
const result = await this.wechatPay.jsapiPay('AI磁场月度会员', outTradeNo, VIP_AMOUNT, openid) const title = plan === 'sprint' ? '职引冲刺版月度会员' : '职引成长版月度会员'
const outTradeNo = `${plan === 'sprint' ? 'SPR' : 'VIP'}${Date.now()}${userId.slice(-6)}`
const result = await this.wechatPay.jsapiPay(title, outTradeNo, amount, openid)
// 保存订单 await this.orderModel.create({ outTradeNo, userId, userPhone: user.phone || '', amount, title, status: 'pending', channel: 'jsapi', plan })
await this.orderModel.create({
outTradeNo,
userId,
userPhone: user.phone || '',
amount: VIP_AMOUNT,
title: 'AI磁场月度会员',
status: 'pending',
channel: 'jsapi',
})
return result return { ...result, outTradeNo }
} }
/** 支付回调通知 */ /** 支付回调通知 */
@Public() @Public()
@Post('notify') @Post('notify')
async notify(@Body() body: any) { async notify(
@Body() body: any,
@Req() req: any,
) {
try { try {
const decrypted = this.wechatPay.verifyAndDecrypt(body, '', '', '') const wechatSignature = req.headers['wechatpay-signature'] || ''
const wechatTimestamp = req.headers['wechatpay-timestamp'] || ''
const wechatNonce = req.headers['wechatpay-nonce'] || ''
const decrypted = this.wechatPay.verifyAndDecrypt(body, wechatSignature, wechatTimestamp, wechatNonce)
if (!decrypted) return { code: 'FAIL', message: '验签失败' } if (!decrypted) return { code: 'FAIL', message: '验签失败' }
const outTradeNo = decrypted.out_trade_no const outTradeNo = decrypted.out_trade_no
const wxTransactionId = decrypted.transaction_id const wxTransactionId = decrypted.transaction_id
// 更新订单状态 // 从数据库订单查找 userId,而非从 outTradeNo 解析
const order = await this.orderModel.findOne({ outTradeNo }).exec() const order = await this.orderModel.findOne({ outTradeNo }).exec()
if (order && order.status === 'pending') { if (!order) {
this.logger.warn(`支付回调:订单不存在 ${outTradeNo}`)
return { code: 'FAIL', message: '订单不存在' }
}
if (order.status === 'pending') {
order.status = 'success' order.status = 'success'
order.paidAt = new Date() order.paidAt = new Date()
order.wxTransactionId = wxTransactionId order.wxTransactionId = wxTransactionId
@@ -96,19 +94,25 @@ export class PaymentController {
await order.save() await order.save()
} }
// 开通会员 // 根据订单 plan 激活对应套餐
const userId = outTradeNo.slice(-6) const user = await this.userModel.findById(order.userId).exec()
const user = await this.userModel.findOne({ _id: { $regex: userId + '$' } }).exec() if (user && user.plan === 'free') {
if (user && user.plan !== 'vip') {
const expireAt = new Date() const expireAt = new Date()
expireAt.setDate(expireAt.getDate() + VIP_DURATION_DAYS) expireAt.setDate(expireAt.getDate() + VIP_DURATION_DAYS)
user.plan = 'vip' if (order.plan === 'sprint') {
user.plan = 'sprint'
user.sprintExpireAt = expireAt
user.sprintRemaining = 10 // 每月 10 次冲刺权益(语音分析+缺口分析)
} else {
user.plan = 'growth'
user.vipExpireAt = expireAt user.vipExpireAt = expireAt
}
user.remaining = 999 user.remaining = 999
await user.save() await user.save()
} }
return { code: 'SUCCESS', message: '成功' } return { code: 'SUCCESS', message: '成功' }
} catch { } catch (e) {
this.logger.error(`支付回调处理失败: ${e.message}`)
return { code: 'FAIL', message: '处理失败' } return { code: 'FAIL', message: '处理失败' }
} }
} }
@@ -119,4 +123,39 @@ export class PaymentController {
async query(@Body('outTradeNo') outTradeNo: string) { async query(@Body('outTradeNo') outTradeNo: string) {
return this.wechatPay.queryOrder(outTradeNo) return this.wechatPay.queryOrder(outTradeNo)
} }
/** 检查本地订单状态(前端轮询) */
@UseGuards(JwtAuthGuard)
@Get('check/:outTradeNo')
async checkOrder(@Param('outTradeNo') outTradeNo: string, @CurrentUser('userId') userId: string) {
const order = await this.orderModel.findOne({ outTradeNo, userId }).exec()
if (!order) throw new HttpException('订单不存在', HttpStatus.NOT_FOUND)
return { status: order.status, plan: order.plan }
}
/** 凭订单号激活套餐(前端支付成功后调用,兜底) */
@UseGuards(JwtAuthGuard)
@Post('activate')
async activate(@CurrentUser('userId') userId: string, @Body('outTradeNo') outTradeNo: string) {
const order = await this.orderModel.findOne({ outTradeNo, userId }).exec()
if (!order) throw new HttpException('订单不存在', HttpStatus.NOT_FOUND)
if (order.status !== 'success') throw new HttpException('支付未完成', HttpStatus.BAD_REQUEST)
const user = await this.userModel.findById(userId).exec()
if (!user) throw new HttpException('用户不存在', HttpStatus.NOT_FOUND)
if (user.plan !== 'free') return { success: true, plan: user.plan, message: '已是会员' }
const expireAt = new Date()
expireAt.setDate(expireAt.getDate() + VIP_DURATION_DAYS)
if (order.plan === 'sprint') {
user.plan = 'sprint'
user.sprintExpireAt = expireAt
user.sprintRemaining = 10
} else {
user.plan = 'growth'
user.vipExpireAt = expireAt
}
user.remaining = 999
await user.save()
return { success: true, plan: user.plan }
}
} }
@@ -0,0 +1,63 @@
import { Injectable, Logger } from '@nestjs/common'
import { Cron } from '@nestjs/schedule'
import { InjectModel } from '@nestjs/mongoose'
import { Model } from 'mongoose'
import { WechatTokenService } from './wechat-token.service'
import { DailyQuestion, DailyQuestionDocument } from '../schemas/daily-question.schema'
import { User, UserDocument } from '../user/user.schema'
const DAILY_QUESTION_TEMPLATE_ID = process.env.WX_DAILY_QUESTION_TMPL || ''
@Injectable()
export class DailyQuestionPushService {
private readonly logger = new Logger(DailyQuestionPushService.name)
constructor(
@InjectModel(DailyQuestion.name) private dailyQuestionModel: Model<DailyQuestionDocument>,
@InjectModel(User.name) private userModel: Model<UserDocument>,
private wechatToken: WechatTokenService,
) {}
@Cron('0 8 * * *')
async pushDailyQuestion() {
this.logger.log('开始每日一题推送任务')
if (!DAILY_QUESTION_TEMPLATE_ID) {
this.logger.warn('未配置 WX_DAILY_QUESTION_TMPL,跳过每日推送')
return
}
const today = new Date()
today.setHours(0, 0, 0, 0)
const question = await this.dailyQuestionModel.findOne({ pushed: false }).sort({ date: -1 }).exec()
if (!question) {
this.logger.warn('没有未推送的题目')
return
}
const users = await this.userModel.find({ wxOpenid: { $ne: '', $exists: true } }).exec()
this.logger.log(`找到 ${users.length} 个用户待推送`)
let successCount = 0
for (const user of users) {
if (!user.wxOpenid) continue
const ok = await this.wechatToken.sendSubscribeMessage(
user.wxOpenid,
DAILY_QUESTION_TEMPLATE_ID,
{
thing1: { value: question.question.slice(0, 20) + (question.question.length > 20 ? '...' : '') },
thing2: { value: question.position || '通用' },
thing3: { value: question.referenceAnswer.slice(0, 20) + (question.referenceAnswer.length > 20 ? '...' : '') },
},
'pages/interview/interview',
)
if (ok) successCount++
}
question.pushed = true
await question.save()
this.logger.log(`每日一题推送完成:成功 ${successCount}/${users.length}`)
}
}
@@ -0,0 +1,17 @@
import { Module } from '@nestjs/common'
import { MongooseModule } from '@nestjs/mongoose'
import { DailyQuestionPushService } from './daily-question-push.service'
import { WechatTokenService } from './wechat-token.service'
import { DailyQuestion, DailyQuestionSchema } from '../schemas/daily-question.schema'
import { User, UserSchema } from '../user/user.schema'
@Module({
imports: [
MongooseModule.forFeature([
{ name: DailyQuestion.name, schema: DailyQuestionSchema },
{ name: User.name, schema: UserSchema },
]),
],
providers: [WechatTokenService, DailyQuestionPushService],
})
export class ScheduleModule {}
@@ -0,0 +1,55 @@
import { Injectable, Logger } from '@nestjs/common'
import axios from 'axios'
const WX_APPID = process.env.WX_APPID
const WX_SECRET = process.env.WX_SECRET
@Injectable()
export class WechatTokenService {
private readonly logger = new Logger(WechatTokenService.name)
private accessToken: string = ''
private expiresAt: number = 0
async getAccessToken(): Promise<string> {
if (this.accessToken && Date.now() < this.expiresAt) return this.accessToken
return this.refreshToken()
}
async refreshToken(): Promise<string> {
if (!WX_APPID || !WX_SECRET) {
this.logger.warn('微信配置不完整,无法获取 access_token')
return ''
}
try {
const res = await axios.get('https://api.weixin.qq.com/cgi-bin/token', {
params: { grant_type: 'client_credential', appid: WX_APPID, secret: WX_SECRET },
})
this.accessToken = res.data.access_token
this.expiresAt = Date.now() + (res.data.expires_in - 300) * 1000
this.logger.log(`微信 access_token 刷新成功,有效期 ${res.data.expires_in}s`)
return this.accessToken
} catch (e: any) {
this.logger.error(`获取微信 access_token 失败: ${e.response?.data || e.message}`)
return this.accessToken || ''
}
}
/** 发送订阅消息 */
async sendSubscribeMessage(openid: string, templateId: string, data: Record<string, { value: string }>, pagePath?: string) {
const token = await this.getAccessToken()
if (!token) { this.logger.warn('无有效 access_token,跳过消息推送'); return false }
try {
await axios.post(`https://api.weixin.qq.com/cgi-bin/message/subscribe/send?access_token=${token}`, {
touser: openid,
template_id: templateId,
data,
page: pagePath || 'pages/index/index',
miniprogram_state: 'formal',
})
return true
} catch (e: any) {
this.logger.error(`发送订阅消息失败: ${e.response?.data || e.message}`)
return false
}
}
}
+6
View File
@@ -29,6 +29,12 @@ export class User {
@Prop() @Prop()
vipExpireAt?: Date vipExpireAt?: Date
@Prop()
sprintExpireAt?: Date
@Prop({ default: 0 })
sprintRemaining: number // 冲刺版剩余次数(语音分析等)
@Prop({ default: 'user' }) @Prop({ default: 'user' })
role: string // 'user' | 'admin' role: string // 'user' | 'admin'
+196 -342
View File
@@ -1,9 +1,9 @@
# 职引 — 技术架构文档 v3.0 # 职引 — 技术架构文档 v4.0
> 版本: v3.0 > **版本**: v4.0
> 日期: 2026-06-01 > **日期**: 2026-06-09
> 状态: 重新定位后 > **状态**: Phase 0.5 完成
> 定位: 应届生/实习生 AI 面试教练 > **定位**: 应届生/实习生 AI 面试教练
--- ---
@@ -17,403 +17,256 @@
| Vue | 3.4 | 框架 | | Vue | 3.4 | 框架 |
| TypeScript | 5.x | 类型安全 | | TypeScript | 5.x | 类型安全 |
| Vite | 5.x | 构建工具 | | Vite | 5.x | 构建工具 |
| Pinia | 2.x | 状态管理(用户状态、面试会话) |
| SCSS | - | 样式预处理 |
### 1.2 后端 ### 1.2 后端
| 技术 | 版本 | 用途 | | 技术 | 版本 | 用途 |
|------|------|------| |------|------|------|
| Node.js | 18+ | 运行环境 | | Node.js | 18+ | 运行环境 |
| NestJS | 10.x | 框架(模块化,适合快速迭代) | | NestJS | 10.x | 框架 |
| MongoDB + Mongoose | 7.x | 数据库(灵活 schema,适合快速迭代) | | MongoDB + Mongoose | 7.x | 数据库 |
| MongoDB Atlas | - | 免费层起步,0 成本启动 | | JWT | - | 认证 |
| JWT | - | 用户认证(微信登录对接后使用) |
| class-validator | - | 参数校验 | | class-validator | - | 参数校验 |
| 微信支付 | - | 会员付费(Phase 1.5 接入) |
### 1.3 AI 能力 ### 1.3 AI 能力
| 技术 | 用途 | 优先级 | | 技术 | 用途 | 优先级 |
|------|------|--------| |------|------|--------|
| opencode-go (deepseek-v4-flash) | 面试模拟、简历诊断、面试报告 | 主用 | | opencode-go (deepseek-v4-flash) | 面试模拟、简历诊断、报告 | 主用 |
| NVIDIA (stepfun-ai/step-3.5-flash) | 主用不可用时自动切换 | 备用 | | NVIDIA (stepfun-ai/step-3.5-flash) | 主用不可用时自动切换 | 备用 |
### 1.4 部署 ### 1.4 部署
| 技术 | 用途 | | 技术 | 用途 |
|------|------| |------|------|
| 腾讯云轻量应用服务器 | 后端部署(个人开发者友好,已 ICP 备案) | | 腾讯云轻量应用服务器 | 后端部署(已 ICP 备案) |
| 微信小程序云开发(可选) | 静态资源 + 云函数(快速原型) | | Nginx | 反向代理(HTTPS 终止) + H5 静态文件服务 |
| Nginx | 反向代理(HTTPS 终止) | | PM2 | 进程管理 |
**生产域名**
| 域名 | 用途 | 指向 |
|------|------|------|
| `zhiyinwx.yzrcloud.cn` | 后端 API(小程序服务端 + H5 API | Nginx → `localhost:3006` |
| `zhiyin.yzrcloud.cn` | H5 网页端 | `/www/wwwroot/zhiyin.yzrcloud.cn` |
--- ---
## 二、项目结构(简化版,MVP 优先) ## 二、项目结构
``` ```
zhiyin/ zhiyin/
├── zhiyin-app/ # 小程序前端 ├── zhiyin-app/ # 小程序前端
── src/ ── src/
├── pages/ # 页面 ├── pages/ # 16 个页面
│ ├── index/ # 首页(岗位选择 + 面试入口 │ ├── index/ # 首页(Tab: 面试)
│ ├── interview/ # 面试模拟(核心页面) │ ├── interview/ # 面试模拟
│ ├── report/ # 面试报告 │ ├── report/ # 面试报告
│ ├── history/ # 历史面试记录 │ ├── history/ # 历史记录Tab: 面经)
│ ├── resume/ # 简历诊断(Phase 1.5 │ ├── user/ # 个人中心(Tab: 我的
│ ├── internship/ # 实习搜索聚合(MVP 跳转模式) │ ├── login/ # 登录/注册
│ ├── user/ # 个人中心 │ ├── member/ # 会员中心
── member/ # 会员中心(Phase 1.5 ── progress/ # 进步轨迹雷达图
│ │ ├── components/ # 组件 │ ├── contribute/ # 面经贡献
│ │ ├── services/ # API 服务 │ ├── resume/ # 简历诊断/优化
│ │ ├── stores/ # Pinia 状态 │ ├── result/ # 优化结果
│ │ ├── styles/ # 全局样式 │ ├── internship/ # 实习搜索
│ │ ├── utils/ # 工具函数 │ ├── admin/ # 管理后台
│ │ ├── App.vue │ ├── about/ # 关于
│ │ ── main.ts ── agreement/ # 用户协议
├── package.json │ └── privacy/ # 隐私政策
├── vite.config.js ├── services/api.ts # API 服务封装
└── manifest.json # 小程序配置(AI 深度合成类目已通过) ├── config.ts # 全局配置/端点定义
│ ├── pages.json # 路由表(3 Tab + 13 页面)
│ └── manifest.json # 小程序配置
├── backend/ # 后端服务 ├── backend/ # 后端服务 (NestJS)
── src/ ── src/
├── common/ # 公共模块 ├── main.ts # 入口(端口 3006, 前缀 /api
│ │ ├── filters/ # 异常过滤器 ├── app.module.ts # 根模块(导入全部 14 个子模块)
│ │ ├── guards/ # 权限守卫(JWT ├── common/ # 公共模块
│ ├── interceptors/ # 拦截器(日志、响应格式) │ ├── decorators/ # @CurrentUser, @Public
── utils/ # 工具函数 ── guards/ # JwtAuthGuard
│ │ ├── config/ # 配置文件(数据库、AI API、微信) │ ├── strategies/ # JwtStrategy
│ │ ── dto/ # 数据传输对象 ── filters/ # AllExceptionsFilter
── modules/ # 业务模块MVP 只做 3 个核心模块) ── modules/ # 15 个业务模块
├── interview/ # 面试模块(核心,P0 ├── user/ # 用户(手机/邮箱/密码/微信登录
├── user/ # 用户模块(微信登录,P0 ├── interview/ # AI 面试(核心,含进度追踪
├── resume/ # 简历模块(Phase 1.5P1 ├── ai/ # AI 调用封装(主/备切换
├── member/ # 会员模块(Phase 1.5P1 ├── analyze/ # 简历诊断/优化 AI 分析
├── internship/ # 实习搜索模块(MVP 跳转,P1) ├── resume/ # 简历 CRUD
└── knowledge/ # 知识图谱模块(Phase 2,P2) ├── member/ # 会员套餐/状态
├── ai/ # AI 能力封装(opencode-go + NVIDIA 切换 ├── payment/ # 微信支付 v3(签名/解密/回调
├── wechat/ # 微信相关(登录、支付、订阅消息) ├── positions/ # 热门岗位维护
├── app.module.ts ├── upload/ # 文件上传
│ └── main.ts ├── admin/ # 管理后台 API
├── package.json ├── email/ # 邮件发送
└── tsconfig.json ├── progress/ # 进步轨迹四维统计
│ ├── contribution/ # 面经贡献 + 公司题库
│ ├── daily-question/ # 每日一题(待完善推送)
│ └── schemas/ # 共享数据模型(5 个 schema)
└── docs/ # 项目文档 └── docs/ # 项目文档
├── PRODUCT-PLAN.md # 产品规划(✅ 已更新 v3.0) ├── PRODUCT-PLAN.md
├── ARCHITECTURE.md # 架构文档(✅ 当前文件) ├── ARCHITECTURE.md
├── FEATURE-LIST.md # 功能清单(待更新) ├── FEATURE-LIST.md
├── ROADMAP.md # 路线图(待更新) ├── ROADMAP.md
── PROJECT-STATUS.md # 进度文档(待更新) ── PROJECT-STATUS.md
├── DEPLOYMENT.md
└── WECHAT-CHECKLIST.md
``` ```
--- ---
## 三、核心数据模型(简化,快速迭代) ## 三、数据模型
### 3.1 用户User ### 3.1 用户 (User)
```
```javascript phone, wxOpenid, email, password, nickname, avatar
{ plan (free/growth), remaining, vipExpireAt
_id: ObjectId, role (user/admin), isSystemAdmin
openid: String, // 微信 openid(唯一标识) interviewCount
nickname: String, // 微信昵称
avatar: String, // 微信头像
targetPosition: String, // 目标岗位(校招热门岗位)
targetIndustry: String, // 目标行业
memberStatus: { // 会员状态
type: String, // free/vip
expireAt: Date,
},
usage: { // 使用统计
dailyInterviewCount: Number, // 每日面试次数
totalInterviewCount: Number, // 总面试次数
lastResetDate: Date,
},
createdAt: Date,
updatedAt: Date
}
``` ```
### 3.2 面试会话(Interview)—— 核心模型 ### 3.2 面试 (Interview)
```
```javascript userId, position, status (in_progress/completed)
{ messages[{role, content, score, feedback, suggestion}]
_id: ObjectId, totalScore, questionCount
userId: ObjectId, summary{dimensionScores{logic,expression,professionalism,stability}, strengths, weaknesses, suggestions}
position: String, // 面试岗位(如"前端工程师")
status: String, // pending/active/completed
rounds: [{ // 问答轮次
question: String, // AI 问题
answer: String, // 用户回答
feedback: { // AI 反馈
score: Number, // 本题得分(0-100
comment: String, // 评语
suggestion: String, // 改进建议
referenceAnswer: String, // 参考回答思路
},
createdAt: Date,
}],
summary: { // 面试总结
totalScore: Number, // 总分(0-100
strengths: [String], // 优势
weaknesses: [String], // 不足
suggestions: [String], // 综合建议
dimensionScores: { // 各维度得分
logic: Number, // 逻辑思维
expression: Number, // 表达能力
professionalism: Number, // 专业度
stability: Number, // 情绪稳定性
},
},
createdAt: Date,
completedAt: Date
}
``` ```
### 3.3 简历(Resume)—— Phase 1.5 ### 3.3 进步轨迹 (Progress)
```
```javascript userId, totalInterviews, completedInterviews
{ avgLogic, avgExpression, avgProfessionalism, avgStability
_id: ObjectId, streak, lastInterviewDate, streakHistory
userId: ObjectId, recentScores[{interviewId, date, position, totalScore, dimensions}]
title: String, // 简历标题
originalContent: String, // 原始内容
targetPosition: String, // 目标岗位
createdAt: Date,
updatedAt: Date
}
``` ```
### 3.4 岗位题库(QuestionBank)—— Phase 2 知识图谱前置 ### 3.4 面经贡献 (Contribution)
```
userId, interviewId, company, position, rounds, questions, experience, tags, verified
```
```javascript ### 3.5 公司题库 (CompanyBank)
{ ```
_id: ObjectId, company, position
position: String, // 岗位名称(如"前端工程师") questions[{content, type, referenceAnswer, difficulty, frequency, tags}]
category: String, // 分类(技术/职能/AI专项) contributionCount, viewCount
difficulty: String, // 难度(junior/mid/senior ```
questions: [{
content: String, // 问题内容 ### 3.6 每日一题 (DailyQuestion)
type: String, // 类型(basic/algorithm/project/behavioral ```
referenceAnswer: String, // 参考回答 position, question, referenceAnswer, category, date, pushed
tags: [String], // 标签(如"闭包"、"贪心算法" ```
}],
createdAt: Date, ### 3.7 支付订单 (PaymentOrder)
updatedAt: Date ```
} outTradeNo, userId, amount, status, channel (native/jsapi)
paidAt, wxTransactionId, refundAmount, refundedAt
```
### 3.8 简历/岗位
```
Resume: userId, title, originalContent, targetPosition
HotPosition: name, salary, company, icon, sort, active
``` ```
--- ---
## 四、API 接口设计(MVP 核心接口) ## 四、API 接口总览
### 4.1 用户模块(微信登录) ### 4.1 用户 (prefix: `/api/user`)
| 方法 | 路径 | 说明 |
|------|------|------|
| POST | `/send-code` | 发送手机验证码 |
| POST | `/login` | 手机号+验证码登录 |
| POST | `/send-email-code` | 发送邮箱验证码 |
| POST | `/email-login` | 邮箱+验证码登录 |
| POST | `/password-login` | 邮箱+密码登录 |
| POST | `/register` | 邮箱+密码注册 |
| POST | `/wx-login` | 微信静默登录 |
| GET | `/info` | 获取用户信息 |
| PUT | `/update` | 更新用户信息 |
| GET | `/usage` | 获取使用额度 |
| POST | `/set-password` | 设置密码 |
| 接口 | 方法 | 路径 | 说明 | ### 4.2 面试 (prefix: `/api/interview`)
|------|------|------|------| | 方法 | 路径 | 说明 |
| 微信登录 | POST | /api/user/wx-login | 微信授权登录,返回 token | |------|------|------|
| 获取用户信息 | GET | /api/user/info | 获取用户信息 | | POST | `/create` | 创建面试 |
| 更新用户信息 | PUT | /api/user/update | 更新目标岗位/行业 | | POST | `/:id/answer` | 提交回答 |
| POST | `/:id/complete` | 完成面试生成报告 |
| GET | `/:id` | 获取面试详情 |
| GET | `/list/all` | 面试历史列表 |
| GET | `/stats/mine` | 用户统计 |
### 4.2 面试模块(核心) ### 4.3 简历/AI 分析 (prefix: `/api/analyze`, `/api/resume`)
| 方法 | 路径 | 说明 |
|------|------|------|
| POST | `/analyze/diagnosis` | AI 简历诊断 |
| POST | `/analyze/optimize` | AI 简历优化 |
| POST | `/resume/create` | 创建简历 |
| GET | `/resume/list` | 简历列表 |
| DELETE | `/resume/:id` | 删除简历 |
| 接口 | 方法 | 路径 | 说明 | ### 4.4 会员/支付 (prefix: `/api/member`, `/api/payment`)
|------|------|------|------| | 方法 | 路径 | 说明 |
| 创建面试 | POST | /api/interview/create | 创建面试会话(选岗位) | |------|------|------|
| 获取面试 | GET | /api/interview/:id | 获取面试详情 | | GET | `/member/plans` | 套餐配置 |
| 面试列表 | GET | /api/interview/list | 获取用户面试列表 | | GET | `/member/status` | 会员状态 |
| 提交回答 | POST | /api/interview/:id/answer | 提交本轮回答 | | POST | `/member/pay` | ⚠️ 开发绕过(上线前移除) |
| 获取反馈 | GET | /api/interview/:id/feedback | 获取本轮反馈 | | POST | `/payment/create` | 创建 Native 支付订单 |
| 结束面试 | POST | /api/interview/:id/complete | 结束面试生成报告 | | POST | `/payment/jsapi` | 创建 JSAPI 支付 |
| POST | `/payment/notify` | 微信支付回调 |
| POST | `/payment/query` | 查询订单状态 |
### 4.3 实习搜索模块(MVP 跳转模式) ### 4.5 Phase 0.5 (prefix: `/api/progress`, `/api/contribution`, `/api/daily-question`)
| 方法 | 路径 | 说明 |
|------|------|------|
| GET | `/progress` | 进步轨迹数据 |
| GET | `/progress/stats` | 进步轨迹统计 |
| POST | `/contribution` | 提交面经贡献 |
| GET | `/contribution/my` | 我的贡献列表 |
| GET | `/contribution/company/:c/position/:p` | 公司题库查询 |
| GET | `/contribution/company/:c` | 公司岗位列表 |
| GET | `/daily-question` | 获取每日一题 |
| GET | `/daily-question/position/:p` | 按岗位获取题目 |
| 接口 | 方法 | 路径 | 说明 | ### 4.6 其他
|------|------|------|------| | 方法 | 路径 | 说明 |
| 实习搜索 | GET | /api/internship/search | 搜索实习岗位(聚合入口) | |------|------|------|
| 热门实习 | GET | /api/internship/hot | 热门实习岗位列表 | | GET | `/positions/hot` | 热门岗位 |
| POST | `/upload` | 文件上传 |
> MVP 阶段:不存数据,只做搜索聚合入口(跳转模式),低成本验证用户需求。 | GET | `/admin/*` | 管理后台接口 |
### 4.4 简历模块(Phase 1.5
| 接口 | 方法 | 路径 | 说明 |
|------|------|------|------|
| 创建简历 | POST | /api/resume/create | 创建简历记录 |
| 诊断简历 | POST | /api/resume/diagnosis | AI 诊断简历问题 |
### 4.5 会员模块(Phase 1.5
| 接口 | 方法 | 路径 | 说明 |
|------|------|------|------|
| 会员订阅 | POST | /api/member/subscribe | 微信支付订阅 |
| 获取会员状态 | GET | /api/member/status | 获取会员信息 |
--- ---
## 五、AI 面试模拟核心流程 ## 五、部署架构
``` ```
用户选择岗位 微信小程序 (用户端) H5 浏览器 (用户端)
↓ HTTPS ↓ HTTPS
zhiyinwx.yzrcloud.cn zhiyin.yzrcloud.cn
↓ ↓
Nginx 反向代理 (腾讯云轻量应用服务器) Nginx 静态文件服务
↓ ↓
backend (NestJS :3006) /www/wwwroot/zhiyin.yzrcloud.cn/
创建面试会话(/api/interview/create MongoDB Atlas / 自建 MongoDB
AI 生成第一题(基于岗位 + 用户简历)
用户语音/文字回答
提交回答(/api/interview/:id/answer
AI 实时反馈(评分 + 评语 + 建议 + 参考回答)
AI 追问(基于回答内容)
循环(通常 5-8 轮)
用户结束面试
生成面试报告(/api/interview/:id/complete
展示报告(总分 + 各维度得分 + 优劣势 + 建议)
```
### 5.1 AI Prompt 设计要点
**系统提示词(System Prompt**
```
你是一位专业的校招面试官,正在面试一位应聘{position}岗位的应届毕业生。
请根据以下要求进行了面试:
1. 问题要符合校招难度(不要求工作经验)
2. 结合应聘岗位提出专业问题
3. 根据考生回答进行针对性追问
4. 每次回答后给出评分(0-100)和具体改进建议
5. 面试共 5-8 轮,涵盖基础、项目、算法(如适用)、行为问题
```
**反馈提示词(Feedback Prompt**
```
请对以下面试回答进行评分和反馈:
问题:{question}
回答:{answer}
岗位:{position}
输出格式(JSON):
{
"score": 85,
"comment": "回答逻辑清晰,但缺少具体案例...",
"suggestion": "建议结合项目经历,用STAR法则组织回答",
"referenceAnswer": "参考回答思路:..."
}
```
---
## 六、部署架构
```
微信小程序 (用户端)
↓ HTTPS
Nginx 反向代理(腾讯云轻量应用服务器)
backend (NestJS :3000)
MongoDB Atlas (数据库)
AI API (opencode-go → NVIDIA 主备切换) AI API (opencode-go → NVIDIA 主备切换)
``` ```
### 6.1 合规优势
-**ICP 备案已完成**(合规运营基础)
-**AI 深度合成类目已通过**(可立即上线,竞品需 3-6 个月)
- ✅ 微信小程序已配置(manifest.json
--- ---
## 七、关键技术决策 ## 六、安全与合规
### 7.1 为什么继续用 NestJS + MongoDB - ✅ ICP 备案已完成
- ✅ AI 深度合成类目已通过
- 已有代码基础,重写浪费时间 - ✅ JWT 认证全局守卫(白名单接口 @Public 放行)
- NestJS 模块化适合快速迭代(MVP → Phase 1.5 → Phase 2 - ✅ 请求限流 (10次/分钟)
- MongoDB 灵活的数据模型适合快速改 schema(早期频繁迭代) - ⚠️ 生产环境需配置:强 JWT_SECRET、CORS 白名单、HTTPS、移除固定验证码
- 个人开发者能 hold 住(社区资源丰富)
### 7.2 为什么 MVP 先做 3 个核心模块?
- **面试模块**(核心差异,必须做好)
- **用户模块**(微信登录,0 门槛)
- **实习搜索**(跳转模式,低成本验证需求)
其他模块(简历、会员、知识图谱)MVP 后按优先级加入。
### 7.3 为什么实习搜索用跳转模式?
- 不存数据 = 0 合规风险
- 不爬数据 = 0 维护成本
- 只做聚合搜索入口 = 低成本验证用户需求
- 如果需求验证成功,Phase 1.5 再做"精选实习岗位"(人工整理)
### 7.4 AI 模型选型
- **主用**opencode-go (deepseek-v4-flash) — 性能优先,成本低
- **备用**NVIDIA (stepfun-ai/step-3.5-flash) — 主用不可用时自动降级
- **合规**:已备案,可合法提供 AI 服务
---
## 八、MVP 开发优先级
### P0Week 1-2,必须完成)
- [ ] 微信登录对接(/api/user/wx-login
- [ ] 岗位选择页面(校招热门 20+ 岗位,含 AI 岗位)
- [ ] AI 面试模拟核心逻辑(多轮对话)
- [ ] 每轮反馈评分接口(/api/interview/:id/feedback
- [ ] 面试报告生成(/api/interview/:id/complete
- [ ] 历史面试记录查看(/api/interview/list
### P1Week 3-4PMF 验证后)
- [ ] 简历诊断功能(复用已有代码)
- [ ] 会员系统开发(¥9.9/月)
- [ ] 微信支付对接
- [ ] 实习搜索聚合入口(跳转模式)
### P2Week 5-8,增强功能)
- [ ] AI 岗位专属题库(算法/大模型)
- [ ] 技能缺口分析
- [ ] 知识图谱(校招岗位-技能映射)
- [ ] 精选实习岗位(人工整理)
---
## 九、性能与扩展性考虑
### 9.1 性能优化
- AI 调用异步化(避免阻塞用户操作)
- 面试报告生成用队列(防止并发超时)
- MongoDB 索引优化(userId, position, createdAt
### 9.2 扩展性考虑
- 模块化设计(NestJS 天然支持)
- AI 模型可插拔(opencode-go / NVIDIA / 未来接入其他模型)
- 数据库分片预留(用户量大的时候)
- 微信支付/订阅消息可替换(如果未来做独立 App)
---
## 十、安全风险与应对
| 风险 | 影响 | 应对 |
|------|------|------|
| AI 内容合规 | 微信下架 | 严格遵守微信 AI 内容规范;敏感词过滤;人工审核机制 |
| 用户数据泄露 | 信任危机 | HTTPS 全站;数据库访问权限控制;不存敏感信息 |
| 微信登录伪造 | 账户被盗 | 签名验证;openid 绑定校验 |
| AI API 费用失控 | 成本飙升 | 限制每日调用次数;监控告警;备用模型自动切换 |
--- ---
@@ -421,6 +274,7 @@ AI API (opencode-go → NVIDIA 主备切换)
| 日期 | 变更内容 | 操作人 | | 日期 | 变更内容 | 操作人 |
|------|----------|--------| |------|----------|--------|
| 2026-05-14 | 职引项目启动,架构文档初版 | AI | | 2026-05-14 | 初版 | AI |
| 2026-05-15 | 方向调整:简历工具 → AI 职业发展平台 | AI | | 2026-06-01 | 重新架构:专注校招 | AI |
| 2026-06-01 | **重新架构**:专注校招,简化 MVP,价格 ¥9.9/月,合规优势 | AI | | 2026-06-09 | 全面重写:匹配真实 15 模块 + 16 页面 + API 清单 | AI |
| 2026-06-09 | 更新部署架构:添加生产域名 zhiyinwx.yzrcloud.cn / zhiyin.yzrcloud.cn | 小之 |
+123 -131
View File
@@ -1,12 +1,16 @@
# 职引 - 部署文档 # 职引 - 部署文档
> **最后更新**: 2026-06-09
> **生产环境**: 已部署(服务器已购 + 域名已配)
## 目录 ## 目录
1. [环境要求](#环境要求) 1. [环境要求](#环境要求)
2. [后端部署](#后端部署) 2. [生产域名](#生产域名)
3. [端部署H5](#端部署h5) 3. [端部署](#端部署)
4. [微信小程序部署](#微信小程序部署) 4. [前端部署(H5](#前端部署h5)
5. [数据库初始化](#数据库初始化) 5. [微信小程序部署](#微信小程序部署)
6. [监控和日志](#监控和日志) 6. [数据库初始化](#数据库初始化)
7. [安全检查清单](#安全检查清单)
--- ---
@@ -22,7 +26,19 @@
### 本地开发 ### 本地开发
- **Node.js**: 18.x - **Node.js**: 18.x
- **MongoDB**: 本地或云服务 - **MongoDB**: 本地或云服务
- **HBuilderX**: uni-app 开发 - **后端端口**: 3006(默认)
- **前端端口**: 8085Vite 开发服务器)
---
## 生产域名
| 用途 | 域名 | 指向 |
|------|------|------|
| 后端 API(小程序服务端) | `https://zhiyinwx.yzrcloud.cn` | Nginx → `http://localhost:3006` |
| H5 网页端 | `https://zhiyin.yzrcloud.cn` | 静态目录 `/www/wwwroot/zhiyin.yzrcloud.cn` |
端口固定为 **3006**,由 `main.ts``process.env.PORT || 3006` 控制。
--- ---
@@ -31,141 +47,142 @@
### 1. 安装依赖 ### 1. 安装依赖
```bash ```bash
cd backend cd backend
npm install --production npm install
``` ```
### 2. 配置环境变量 ### 2. 配置环境变量
复制 `.env.example``.env.production` 并修改 创建 `.env` 文件(参考 `backend/.env`
```bash ```env
cp .env.example .env.production MONGODB_URI=mongodb://localhost:27017/zhiyin
vim .env.production # 修改生产配置 JWT_SECRET=your-strong-secret-at-least-32-chars
PORT=3006
AI_PRIMARY_KEY=your-deepseek-key
AI_BACKUP_KEY=your-nvidia-key
WECHAT_APPID=your-appid
WECHAT_MCHID=your-mchid
WECHAT_API_KEY=your-api-v3-key
WECHAT_SERIAL_NO=your-cert-serial
WECHAT_PRIVATE_KEY_PATH=/path/to/apiclient_key.pem
WX_DAILY_QUESTION_TMPL=在微信公众平台配置的订阅消息模板ID
``` ```
**关键配置项** ### 3. 编译并启动
- `MONGODB_URI`: 生产数据库地址
- `JWT_SECRET`: 强密钥(至少32字符)
- `PORT`: 生产端口(推荐 3000
- `WECHAT_APPID` / `WECHAT_SECRET`: 小程序生产凭证
### 3. 编译 TypeScript
```bash ```bash
npm run build npm run build # 编译 TypeScript → dist/
node dist/main.js # 启动(或使用 PM2
``` ```
### 4. 使用 PM2 启动 ### 4. PM2 进程管理
```bash ```bash
# 安装 PM2
npm install -g pm2 npm install -g pm2
# 启动应用
pm2 start dist/main.js --name zhiyin-backend pm2 start dist/main.js --name zhiyin-backend
pm2 startup && pm2 save
# 设置开机自启
pm2 startup
pm2 save
``` ```
### 5. 配置 Nginx 反向代理 ### 5. Nginx 反向代理(后端 API
```nginx ```nginx
server { server {
listen 80; listen 443 ssl http2;
server_name api.yourdomain.com; server_name zhiyinwx.yzrcloud.cn;
ssl_certificate /etc/nginx/ssl/yzrcloud.cn.pem;
ssl_certificate_key /etc/nginx/ssl/yzrcloud.cn.key;
location / { location / {
proxy_pass http://localhost:3000; proxy_pass http://localhost:3006;
proxy_http_version 1.1; proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade; proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection 'upgrade'; proxy_set_header Connection 'upgrade';
proxy_set_header Host $host; proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_cache_bypass $http_upgrade; proxy_cache_bypass $http_upgrade;
} }
} }
# HTTP → HTTPS 跳转
server {
listen 80;
server_name zhiyinwx.yzrcloud.cn;
return 301 https://$host$request_uri;
}
``` ```
--- ---
## 前端部署(H5 ## 前端部署(H5
### 1. 修改 API 地址 ### 1. 配置 API 地址
编辑 `zhiyin-app/config/api.js` 编辑 `zhiyin-app/.env.production`
```javascript ```env
export const BASE_URL = 'https://api.yourdomain.com/api' VITE_API_BASE_URL=https://zhiyinwx.yzrcloud.cn/api
``` ```
### 2. 编译 H5 ### 2. 编译
```bash ```bash
cd zhiyin-app cd zhiyin-app
npm run build:h5 npm run build:h5
# 输出: dist/build/h5/
``` ```
### 3. 部署到 Web 服务器 ### 3. 部署到 Web 服务器
`dist/build/h5/` 目录上传到服务器:
```bash ```bash
# 使用 scp 上传 scp -r dist/build/h5/* user@your-server:/www/wwwroot/zhiyin.yzrcloud.cn/
scp -r dist/build/h5/* user@your-server:/var/www/zhiyin/
# 或使用 FTP/SSH 上传
``` ```
### 4. 配置 Nginx ### 4. Nginx 配置
```nginx ```nginx
server { server {
listen 80; listen 443 ssl http2;
server_name yourdomain.com; server_name zhiyin.yzrcloud.cn;
root /www/wwwroot/zhiyin.yzrcloud.cn;
root /var/www/zhiyin;
index index.html; index index.html;
ssl_certificate /etc/nginx/ssl/yzrcloud.cn.pem;
ssl_certificate_key /etc/nginx/ssl/yzrcloud.cn.key;
location / { location / {
try_files $uri $uri/ /index.html; try_files $uri $uri/ /index.html;
} }
# 静态资源缓存 location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff2?|ttf)$ {
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf)$ {
expires 1y; expires 1y;
add_header Cache-Control "public, immutable"; add_header Cache-Control "public, immutable";
} }
} }
# HTTP → HTTPS 跳转
server {
listen 80;
server_name zhiyin.yzrcloud.cn;
return 301 https://$host$request_uri;
}
``` ```
--- ---
## 微信小程序部署 ## 微信小程序部署
### 1. 修改配置 ### 1. 修改 manifest.json
编辑 `zhiyin-app/manifest.json` 编辑 `zhiyin-app/manifest.json`,填写正式 appid。
```json
{
"mp-weixin": {
"appid": "your-production-appid"
}
}
```
### 2. 编译小程序 ### 2. 编译
```bash ```bash
cd zhiyin-app cd zhiyin-app
npm run build:mp-weixin npm run build:mp-weixin
# 输出: dist/build/mp-weixin/
``` ```
### 3. 使用微信开发者工具上传 ### 3. 使用微信开发者工具上传
1. 打开微信开发者工具 1. 导入 `dist/build/mp-weixin/`
2. 导入 `dist/build/mp-weixin/` 目录 2. 点击「上传」
3. 点击「上传」按钮 3. 登录微信公众平台提交审核
4. 填写版本号和项目备注
5. 登录微信公众平台提交审核
### 4. 提交审核前检查
- ✅ 已配置合法域名(在微信公众平台)
- ✅ 已通过 AI 深度合成类目审批
- ✅ 已配置隐私协议和用户协议
- ✅ 已测试所有核心功能
--- ---
## 数据库初始化 ## 数据库初始化
### 1. 创建数据库和用户
```bash ```bash
mongo mongo
> use zhiyin > use zhiyin
@@ -176,84 +193,59 @@ mongo
}) })
``` ```
### 2. 创建索引
```bash
cd backend
node scripts/create-indexes.js
```
### 3. 初始化管理员账号(可选)
```bash
node scripts/create-admin.js
```
--- ---
## 监控和日志 ## 小程序编译上传
### 1. 日志配置 ### 环境要求
使用 PM2 查看日志:
```bash ```bash
pm2 logs zhiyin-backend # 安装 miniprogram-ci(已在项目 devDeps 中)
pm2 logs zhiyin-backend --lines 100 cd zhiyin-app && npm install --save-dev miniprogram-ci
``` ```
### 2. 错误监控 ### 配置
推荐集成 确保以下文件就位
- **Sentry**: 错误追踪 - `dist/build/mp-weixin/` — uni-app 构建输出(`npm run build:mp-weixin`
- **PM2 Plus**: 进程监控 - 微信小程序私钥文件:`/root/opencode-workspace/微信小程序参数/宇之然AI磁场appid/private.wxf466b3c3bc411ffc.key`
- **MongoDB Atlas**: 数据库监控 - `project.config.json`(构建时自动生成,appid: wxf466b3c3bc411ffc
### 3. 性能监控 ### 编译与预览上传
```bash ```bash
# 查看进程状态 cd zhiyin-app
pm2 monit
# 查看资源占用 # 构建微信小程序
pm2 list npm run build:mp-weixin
# 预览(生成二维码)
node scripts/upload-mp.js
# 直接上传(修改脚本中 ci.preview → ci.upload
``` ```
--- ### 版本号
当前线上版本:**1.0.3**(见 note.txt
## 常见问题
### Q1: MongoDB 连接失败
**A**: 检查 `.env.production` 中的 `MONGODB_URI` 是否正确,确保数据库可访问。
### Q2: 微信小程序请求失败
**A**: 确保在微信公众平台配置了合法域名(必须是 HTTPS)。
### Q3: AI 调用失败
**A**: 检查 `AI_PRIMARY_KEY``AI_BACKUP_KEY` 是否正确,确保 API 额度充足。
--- ---
## 安全检查清单 ## 安全检查清单
- [ ] 修改默认 JWT_SECRET - [ ] 修改默认 JWT_SECRET(当前已有环境变量值)
- [ ] 已禁用开发模式的固定验证码 - [ ] 移除开发模式的固定验证码 123456
- [ ] 已配置 CORS 白名单 - [x] 移除 `member/pay` 开发绕过(已改为真实订单校验)
- [ ] 已启用 HTTPS - [x] 套餐值统一:vip → growth/sprint(采访轮次、分析次数不再受限)
- [ ] 配置 MongoDB 访问权限 - [ ] 配置 CORS 白名单(当前: `origin: '*'`
- [ ] 已移除控制台日志(生产环境) - [ ] 启用 HTTPS
- [ ] 配置速率限制(防止滥用) - [ ] 配置 MongoDB 访问权限
- [ ] 移除 console.log(生产环境)
- [x] 配置微信支付生产密钥(证书已验证)
- [x] 配置微信小程序密钥(appid: wxf466b3c3bc411ffc
- [ ] 配置每日一题订阅消息模板 IDWX_DAILY_QUESTION_TMPL
--- ---
## 回滚方案 ## 变更记录
如果需要回滚到上一个版本: | 日期 | 变更内容 | 操作者 |
```bash |------|----------|--------|
# 1. 查看 PM2 历史 | 2026-06-09 | 初版 | AI |
pm2 logs zhiyin-backend --lines 1000 | 2026-06-09 | 更新生产域名:zhiyinwx.yzrcloud.cnAPI :3006)、zhiyin.yzrcloud.cnH5 静态目录) | 小之 |
# 2. 重启到上一个版本
pm2 restart zhiyin-backend --version <previous-version>
# 3. 数据库回滚(如有迁移)
mongorestore --uri="<connection-string>" --drop backup/
```
---
**最后更新**: 2026-06-02
+87 -77
View File
@@ -1,44 +1,45 @@
# 职引 · 完整功能清单 v4.0 # 职引 · 完整功能清单 v4.1
> 版本: v4.0 > **版本**: v4.1
> 日期: 2026-06-05 > **日期**: 2026-06-09
> 状态: 战略升级(竞争壁垒 + 盈利模型重构) > **状态**: Phase 0.5 壁垒构建完成
> 定位: 应届生/实习生 AI 面试教练 > **定位**: 应届生/实习生 AI 面试教练
--- ---
## 一、核心功能AI 面试 + 数据飞轮) ## 一、核心功能
### 1.1 AI 面试模拟(核心差异化) ### 1.1 AI 面试模拟
| 功能 | 状态 | 描述 | 优先级 | | 功能 | 状态 | 描述 | 优先级 |
|------|------|------|--------| |------|------|------|--------|
| 岗位选择 | ✅ 完成 | 校招热门 20+ 岗位(含 AI 算法/大模型岗位) | P0 | | 岗位选择(20+) | ✅ 完成 | 校招热门岗位(含 AI 算法/大模型岗位) | P0 |
| 多轮对话追问 | ✅ 完成 | AI 模拟真实面试官,根据回答连续追问 | P0 | | 多轮对话追问 | ✅ 完成 | AI 模拟真实面试官,根据回答连续追问5-10 轮) | P0 |
| 实时反馈评分 | ✅ 完成 | 每轮回答后给出评分 + 改进建议 | P0 | | 实时反馈评分 | ✅ 完成 | 每轮回答后给出评分 + 改进建议 | P0 |
| 面试报告 | ✅ 完成 | 完整面试表现分析报告(逻辑/表达/专业度/稳定性 | P0 | | 面试报告(四维) | ✅ 完成 | 逻辑/表达/专业度/稳定性 + 优劣势分析 | P0 |
| 历史面试记录 | ✅ 完成 | 查看历史面试与进步轨迹 | P0 | | 历史面试记录 | ✅ 完成 | 查看/筛选/统计 | P0 |
| 参考答案生成 | 🔨 开发中 | 每个问题给出参考回答思路 | P1 | | 参考答案生成 | ✅ 完成 | AI 生成参考回答思路 | P1 |
| 公司真题库 | 🆕 新增 | 按公司+岗位定制面试剧本(首期 5 家公司) | P0 | | 使用限制(免费 5 轮/次) | ✅ 完成 | 免费版 5 轮 / 成长版 10 轮 | P0 |
| AI 岗位专项题库 | 🔨 开发中 | AI 算法/大模型岗位专属面试题库 | P1 | | 公司真题库 | ✅ 完成 | 通过面经贡献自动积累,公司+岗位映射 | P0 |
| AI 岗位专项题库 | 📋 规划中 | AI 算法/大模型岗位专属面试题库 | P1 |
### 1.2 数据飞轮(核心壁垒) ### 1.2 数据飞轮
| 功能 | 状态 | 描述 | 优先级 | | 功能 | 状态 | 描述 | 优先级 |
|------|------|------|--------| |------|------|------|--------|
| 面经贡献系统 | 🆕 新增 | 面试后可填写公司/岗位信息,贡献面试经验 | **P0** | | 面经贡献系统 | ✅ 完成 | 面试后可填写公司/岗位信息,贡献面试经验 | P0 |
| 公司-岗位-题库映射 | 🆕 新增 | 四维数据映射,精准出题 | **P0** | | 公司-岗位-题库映射 | ✅ 完成 | 四维数据映射,精准出题 | P0 |
| 脱敏存储 | 🆕 新增 | 用户回答数据脱敏后存入题库 | **P0** | | 脱敏存储 | ✅ 完成 | 用户回答数据存入公司题库 | P0 |
| 题库自动扩充 | 🆕 新增 | 基于用户贡献自动生成新题目 | P1 | | 题库自动扩充 | ✅ 完成 | 基于用户贡献自动去重 + 频次统计 | P1 |
### 1.3 留存入围(留存壁垒) ### 1.3 留存入围
| 功能 | 状态 | 描述 | 优先级 | | 功能 | 状态 | 描述 | 优先级 |
|------|------|------|--------| |------|------|------|--------|
| 进步轨迹雷达图 | 🆕 新增 | 四维能力(逻辑/表达/专业度/稳定性)可视化 | **P0** | | 进步轨迹雷达图 | ✅ 完成 | 四维能力可视化 + CSS 柱状图 | P0 |
| 历史对比分析 | 🆕 新增 | "你比上次在表达力上提升了 15%" | **P0** | | 历史对比分析 | ✅ 完成 | 最近面试总分/维度趋势 | P0 |
| 连续打卡激励 | 🆕 新增 | 连续 7 天面试 → 解锁高级报告 | P1 | | 连续打卡日历 | ✅ 完成 | 面试频率可视化,连续打卡激励 | P1 |
| 每日一题 Push | 🆕 新增 | 微信订阅消息推送,每日一个面试题 + 参考思路 | **P0** | | 每日一题推送 | ⚠️ 半完成 | 首页展示 + API 读取,**无定时推送** | P0 |
--- ---
@@ -47,49 +48,55 @@
### 2.1 用户认证 ### 2.1 用户认证
| 功能 | 状态 | 描述 | 优先级 | | 功能 | 状态 | 描述 | 优先级 |
|------|------|------|--------| |------|------|------|--------|
| 微信一键登录 | 🔨 开发中 | 微信授权登录,零门槛 | P0 | | 手机验证码登录 | ✅ 完成 | 开发模式固定码 123456 | P0 |
| JWT 认证 | ✅ 完成 | Token 鉴权 | P0 | | 邮箱验证码登录 | ✅ 完成 | H5 使用 | P0 |
| 个人信息设置 | 🔨 开发中 | 目标岗位、求职偏好 | P1 | | 密码登录/注册 | ✅ 完成 | 邮箱+密码 | P0 |
| 微信一键登录 | ✅ 后端 + 前端 | 待联调真实 appid | P0 |
| JWT 认证 | ✅ 完成 | Token 鉴权(7 天过期) | P0 |
| 个人信息设置 | ✅ 完成 | 昵称/头像 | P1 |
### 2.2 个人中心 ### 2.2 个人中心
| 功能 | 状态 | 描述 | 优先级 | | 功能 | 状态 | 描述 |
|------|------|------|--------| |------|------|------|
| 面试记录 | ✅ 完成 | 查看历史面试记录与报告 | P0 | | 面试记录/统计 | ✅ 完成 | 总数/平均分/完成数 |
| 进步轨迹 | 🆕 新增 | 雷达图 + 历史对比 + 打卡进度 | **P0** | | 进步轨迹 | ✅ 完成 | 雷达图 + 打卡日历 |
| 简历管理 | 🔨 开发中 | 管理多份简历 | P1 | | 简历管理 | ✅ 完成 | 多份简历 CRUD + AI 分析 |
| 会员中心 | 🔨 开发中 | 会员状态、订阅、权益展示 | P0 | | 会员中心 | ✅ 完成 | 套餐对比 + 支付 |
--- ---
## 三、商业化功能 ## 三、商业化功能
### 3.1 会员系统(价格重构) ### 3.1 会员系统
| 功能 | 状态 | 描述 | 优先级 | | 功能 | 状态 | 描述 | 优先级 |
|------|------|------|--------| |------|------|------|--------|
| 免费版 | 🔨 开发中 | 日 2 次基础面试(通用题库) | P0 | | 免费版 | ✅ 完成 | 日 2 次面试,5 轮/次 | P0 |
| 成长版 ¥19.9/月 | 🔨 开发中 | 无限面试 + 高级报告 + 公司真题 + 进步轨迹 | **P0** | | 成长版 ¥19.9/月 | ✅ 完成 | 无限面试 + 高级报告 + 进步轨迹 | P0 |
| 冲刺版 ¥49.9/月 | 🆕 新增 | + 真人导师点评 + 简历精修 + 内推优先 | P1 | | 冲刺版 ¥49.9/月 | ❌ 未实现 | 高客单价缺失 | P1 |
| 微信支付对接 | 🔨 开发中 | 微信支付接入 | P0 | | 微信支付 Native(扫码) | ✅ 完成 | H5 支付 | P0 |
| 会员权益对比 | 🆕 新增 | 三版对比展示页面 | P0 | | 微信支付 JSAPI | ✅ 完成 | 小程序内支付 | P0 |
| 支付回调/自动开会员 | ✅ 完成 | 回调验签 + 解密 + 会员激活 | P0 |
| 会员权益对比 | ✅ 完成 | 免费/成长版对比展示 | P0 |
### 3.2 B 端服务(Q4 启动) ### 3.2 B 端服务(Q4 启动)
| 功能 | 状态 | 描述 | 优先级 | | 功能 | 状态 | 描述 |
|------|------|------|--------| |------|------|------|
| 高校就业办合作 | 📋 规划中 | 批量账号 + 数据看板 | P2 | | 高校就业办合作 | 📋 规划中 | 批量账号 + 数据看板 |
| 企业 HR 初筛 | 📋 规划中 | AI 面试初筛工具 | P2 | | 企业 HR 初筛 | 📋 规划中 | AI 面试初筛工具 |
| 内推平台 | 📋 规划中 | 企业发布岗位 + 内推佣金 | P2 | | 内推平台 | 📋 规划中 | 企业发布岗位 + 内推佣金 |
--- ---
## 四、AI 能力 ## 四、AI 能力
| 功能 | 状态 | 描述 | 优先级 |
|------|------|------|--------| | 功能 | 状态 | 描述 |
| AI 面试模拟 | ✅ 完成 | 多轮对话 + 实时反馈 + 评分 | P0 | |------|------|------|
| 面试报告生成 | ✅ 完成 | 总分 + 各维度得分 + 优劣势分析 | P0 | | AI 面试模拟 | ✅ 完成 | 多轮对话 + 实时反馈 + 评分 |
| 简历诊断 | 🔨 开发中 | 结构 + 表达 + 关键词 + 亮点分析 | P1 | | 面试报告生成 | ✅ 完成 | 总分 + 四维 + 优劣势分析 |
| AI 岗位题库 | 🔨 开发中 | AI 算法/大模型岗位专属题库 | P1 | | 简历诊断 | ✅ 完成 | 结构 + 表达 + 关键词 + 亮点分析 |
| 技能缺口分析 | 📋 规划中 | 基于 JD 分析技能差距 | P2 | | 简历优化 | ✅ 完成 | 内容优化 + 差异展示 |
| 学习路径推荐 | 📋 规划中 | 知识图谱驱动的职业规划 | P2 | | 技能缺口分析 | 📋 规划中 | 基于 JD 分析技能差距 |
| 学习路径推荐 | 📋 规划中 | 知识图谱驱动的职业规划 |
### AI 模型配置 ### AI 模型配置
| 模型 | 用途 | 状态 | | 模型 | 用途 | 状态 |
@@ -100,37 +107,38 @@
--- ---
## 五、技术功能 ## 五、技术功能
| 功能 | 状态 | 描述 | | 功能 | 状态 | 描述 |
|------|------|------| |------|------|------|
| MongoDB 数据存储 | ✅ 完成 | 用户、面试、简历、题库 | | MongoDB 数据存储 | ✅ 完成 | 8 个数据模型 |
| Redis 缓存 | ✅ 完成 | 会话缓存,限流 | | JWT 认证 | ✅ 完成 | 全局守卫 + 白名单机制 |
| JWT 认证 | ✅ 完成 | 用户身份验证 | | API 限流 | ✅ 完成 | @nestjs/throttler 10次/分钟 |
| API 限流 | ✅ 完成 | @nestjs/throttler |
| 文件上传 | ✅ 完成 | 简历 PDF/图片解析 | | 文件上传 | ✅ 完成 | 简历 PDF/图片解析 |
| CORS 配置 | ✅ 完成 | 生产环境白名单 | | CORS 配置 | ✅ 完成 | 全开放(生产需白名单 |
| 参数校验 | ✅ 完成 | class-validator whitelist |
--- ---
## 六、功能优先级总览 ## 六、功能优先级总览
### P0MVP + 壁垒构建,立即实现 ### P0已完成 ✅
- [x] 微信一键登录(后端已通,前端待联调 - [x] AI 面试模拟(多轮追问 + 实时反馈 + 评分
- [x] AI 面试模拟(多轮追问 + 实时反馈 - [x] 面试报告生成(四维评分
- [x] 面试报告生成 - [x] 历史面试记录 + 统计
- [x] 历史面试记录 - [x] 进步轨迹雷达图
- [ ] **进步轨迹雷达图**(新增) - [x] 面经贡献系统 + 公司题库
- [ ] **面经贡献系统**(新增 - [x] 每日一题(API 读取
- [ ] **每日一题 Push**(新增) - [x] 手机/邮箱/密码/微信登录
- [ ] **会员系统(¥19.9 + ¥49.9 定价)** - [x] 会员系统(¥19.9 成长版)
- [ ] **微信支付对接** - [x] 微信支付对接Native + JSAPI
- [ ] **公司真题库(首期 5 家)** - [x] 公司真题库(用户贡献驱动)
### P1上线后快速迭代 ### P1待实现
- [ ] 简历诊断(复用已有代码) - [ ] 每日一题定时推送
- [ ] 冲刺版 ¥49.9/月
- [ ] AI 岗位专属题库 - [ ] AI 岗位专属题库
- [ ] 连续打卡激励 - [ ] 连续打卡激励7 天解锁高级报告)
- [ ] 参考答案生成 - [ ] 生产环境部署
- [ ] 会员权益对比页
### P2(秋招后) ### P2(秋招后)
- [ ] 高校就业办合作 - [ ] 高校就业办合作
@@ -140,9 +148,11 @@
--- ---
## 七、变更记录 ## 变更记录
| 日期 | 变更内容 | 操作者 | | 日期 | 变更内容 | 操作者 |
|------|----------|--------| |------|----------|--------|
| 2026-05-14 | 功能清单初版(简历工具方向) | AI | | 2026-05-14 | 初版 | AI |
| 2026-06-01 | 重新定位:专注校招,¥9.9/月 | AI | | 2026-06-01 | 重新定位:专注校招 | AI |
| 2026-06-05 | **战略升级**:新增数据飞轮留存入围、B 端服务;价格重构 ¥19.9/¥49.9 | 小之 | | 2026-06-05 | 战略升级:新增数据飞轮/留存入围 | 小之 |
| 2026-06-09 | 同步代码:Phase 0.5 功能标记完成,修正状态 | AI |
+71 -138
View File
@@ -1,47 +1,30 @@
# 职引 · 产品规划文档 v4.0 # 职引 · 产品规划文档 v4.1
> 版本: v4.0 > **版本**: v4.1
> 日期: 2026-06-05 > **日期**: 2026-06-09
> 状态: 战略升级(竞争壁垒 + 盈利模型重构) > **状态**: Phase 0.5 壁垒构建完成
> 定位: 应届生/实习生 AI 面试教练 > **定位**: 应届生/实习生 AI 面试教练
--- ---
## 一、产品定位 ## 一、产品定位
### 1.1 核心价值
**职引** = 专注校招的 AI 面试教练 **职引** = 专注校招的 AI 面试教练
- **AI 面试模拟**(核心):多轮追问,实时反馈评分,公司真题库 - **AI 面试模拟**(核心):多轮追问,实时反馈评分,公司真题库
- **进步可视化**(留存):四维雷达图,历史对比,连续打卡激励 - **进步可视化**(留存):四维雷达图,历史对比,打卡日历
- **数据飞轮**(壁垒):用户面经贡献 → 题库积累 → 精准出题 - **数据飞轮**(壁垒):用户面经贡献 → 题库积累 → 精准出题
- **简历诊断**(辅助):AI 诊断简历问题,提升通过率 - **简历诊断**(辅助):AI 诊断简历问题,提升通过率
- **B 端服务**(扩展):高校就业办合作、企业 HR 初筛 - **B 端服务**(扩展):高校就业办合作、企业 HR 初筛
### 1.2 Slogan ### Slogan
**"校招面试,先模拟再上场"** **"校招面试,先模拟再上场"**
### 1.3 目标用户
| 用户群体 | 特征 | 核心诉求 |
|---------|------|---------|
| 应届毕业生(春招/秋招) | 第一次面试,零经验 | 不知道面试问什么、怎么答 |
| 实习生招聘 | 大三/研二,找暑期实习 | 面试经验不足,需要练习 |
| 跨专业求职 | 非 CS 转技术岗 | 如何用非相关背景回答专业问题 |
| 高校就业办 | 需要就业指导工具 | 批量提供面试训练能力 |
--- ---
## 二、市场竞争与壁垒 ## 二、市场竞争与壁垒
### 2.1 竞争格局 ### 2.1 三层竞争壁垒
| 类型 | 代表产品 | 优点 | 缺点 | 职引壁垒 |
|------|---------|------|------|---------|
| 通用 AI | ChatGPT/Kimi | 通用灵活 | 非面试专用 | 专注校招,结构化反馈 |
| B 端 AI 面试 | 牛客 | 技术岗题库 | 职能岗弱,C 端一般 | 全岗位覆盖 |
| C 端 AI 面试 | OfferGoose | 功能全面 | ¥99/月贵,校招不专注 | **价格 1/5 + 真题题库** |
| **职引** | **校招专属教练** | **数据飞轮 + 真题 + 低价** | **差异化明显** |
### 2.2 三层竞争壁垒
``` ```
第一层:数据飞轮(核心护城河) 第一层:数据飞轮(核心护城河)
@@ -55,153 +38,103 @@
第二层:用户粘性(留存壁垒) 第二层:用户粘性(留存壁垒)
· 进步轨迹雷达图(四维能力可视化) · 进步轨迹雷达图(四维能力可视化)
· 连续打卡激励(7天解锁高级报告 · 连续打卡日历(面试频率可视化
· 每日一题 Push(面试题 + 参考思路) · 每日一题(面试题 + 参考思路)
· 公司真题库(字节/腾讯/阿里…专属题库 · 公司真题库(用户贡献驱动
· 面试剧本(按公司+岗位定制仿真场景 · 面经贡献(UGC 内容积累
第三层:合规 + 品牌(信任壁垒) 第三层:合规 + 品牌(信任壁垒)
· ICP + AI 深度合成类目已备案 · ICP + AI 深度合成类目已备案
· AI 面试合规白皮书(公开透明)
· 用户口碑 → 校招第一选择
· 竞品备案周期 3-6 个月 · 竞品备案周期 3-6 个月
``` ```
### 2.2 竞品对比
| 类型 | 代表产品 | 职引优势 |
|------|---------|---------|
| 通用 AI | ChatGPT/Kimi | 专注校招,结构化反馈 |
| B 端 AI 面试 | 牛客 | 全岗位覆盖 + 免费优先 |
| C 端 AI 面试 | OfferGoose ¥99/月 | 价格 1/5 + 真题题库 |
--- ---
## 三、盈利模型(重构) ## 三、盈利模型
### 3.1 段式定价 ### 3.1 段式定价
| 版本 | 价格 | 核心权益 | 目标转化率 | | 版本 | 价格 | 核心权益 | 定位 |
|------|------|------|------| |------|------|------|------|
| 免费版 | ¥0 | 日 2 次基础面试(通用题库) | 引流 | | 免费版 | ¥0 | 日 2 次基础面试(通用题库5 轮/次 | 引流 |
| **成长版** | **¥19.9/月** | 无限面试 + 高级报告 + 公司真题库 + 进步轨迹 | **主力70%** | | **成长版** | **¥19.9/月** | 无限面试 + 高级报告 + 进步轨迹 + 真题库 | **主力** |
| **冲刺版** | **¥49.9/月** | + 真人导师点评 + 简历精修 + 内推优先 | 高客单(15%) |
> 价格策略:¥19.9 是 OfferGoose ¥99 的 1/5,心理门槛极低。冲刺版 ¥49.9 锚定"真人服务"价值,反衬成长版超值。 > 冲刺版 ¥49.9/月(含真人导师点评 + 简历精修)待实现
### 3.2 收入来源多元化 ### 3.2 收入来源
``` ```
┌─────────────────┐ C 端订阅收入(基本盘:¥19.9 × 付费用户数)
│ C 端订阅收入 │ ← 基本盘 (¥19.9×用户数)
└────────┬────────┘ ├── B 端合作(高校就业办/求职机构)
├── 内容变现(面经课程)
┌────────────────┼────────────────┐ └── 人才匹配佣金(内推)
│ │ │
▼ ▼ ▼
┌─────────┐ ┌──────────┐ ┌──────────────┐
│ B端合作 │ │ 内容变现 │ │ 人才匹配佣金 │
├─────────┤ ├──────────┤ ├──────────────┤
│· 高校就业│ │· 笔试真题│ │· 企业发布岗位 │
│ 办合作 │ │ 题库 │ │ 位费 │
│· 求职机构│ │· AI 面经 │ │· 内推成功佣金 │
│· 企业 HR │ │ 课程 │ │· 简历筛选服务 │
│ 初筛工具│ │· 1v1辅导 │ │ │
└─────────┘ └──────────┘ └──────────────┘
``` ```
### 3.3 收入预测 ### 3.3 收入预测
| 阶段 | C 端 | B 端 | 月收入 | | 阶段 | C 端 | B 端 | 月收入 |
|------|------|------|------| |------|------|------|--------|
| MVP 上线(6-8月) | 200 付费 × ¥19.9 | 0 | ¥3,980 | | MVP 上线(6-8月) | 200 付费 × ¥19.9 | 0 | ¥3,980 |
| 秋招旺季(9-11月) | 1000 付费 × ¥19.9 | 2 高校 ¥5000 | ¥29,900 | | 秋招旺季(9-11月) | 1000 付费 × ¥19.9 | 2 高校 ¥5000 | ¥29,900 |
| 稳定运营(次年) | 2000 付费 × ¥19.9 | 5 高校 + 企业 | ¥60,000+ | | 稳定运营(次年) | 2000 付费 × ¥19.9 | 5 机构 + 企业 | ¥60,000+ |
--- ---
## 四、核心功能规划 ## 四、当前功能状态
### 4.1 MVP 功能(当前实现中 ### 已上线(Phase 0 + 0.5
| 功能 | 描述 | 优先级 | 状态 | | 功能 | 状态 |
|------|------|--------|------| |------|------|
| 微信一键登录 | 微信授权登录,零门槛 | P0 | 🔨 开发中 | | 手机/邮箱/密码/微信登录 | ✅ 完成 |
| 岗位选择 | 校招热门 20+ 岗位(含 AI 岗位) | P0 | ✅ 完成 | | AI 面试模拟(20+ 岗位) | ✅ 完成 |
| AI 面试模拟 | 多轮对话追问,核心差异化 | P0 | ✅ 完成 | | 实时反馈评分 + 参考回答 | ✅ 完成 |
| 实时反馈评分 | 每轮回答后给出评分+改进建议 | P0 | ✅ 完成 | | 面试报告(四维评分) | ✅ 完成 |
| 面试报告 | 完整面试表现分析报告 | P0 | ✅ 完成 | | 历史记录 + 统计 | ✅ 完成 |
| 历史面试记录 | 查看历史面试与进步轨迹 | P0 | ✅ 完成 | | 进步轨迹雷达图 + 打卡日历 | ✅ 完成 |
| 面经贡献系统 + 公司题库 | ✅ 完成 |
| 每日一题(API) | ✅ 完成 |
| 简历诊断 + 优化 | ✅ 完成 |
| 会员系统(成长版 ¥19.9/月) | ✅ 完成 |
| 微信支付(Native + JSAPI | ✅ 完成 |
### 4.2 Phase 1 增强(当前优先实现 ### 实现
| 功能 | 描述 | 优先级 | | 功能 | 计划 |
|------|------|--------| |------|------|
| 进步轨迹雷达图 | 四维能力可视化 + 历史对比 | **P0 新增** | | 每日一题定时推送(微信订阅消息) | Phase 1 |
| 面经贡献系统 | 面试后可贡献公司面经 → 数据飞轮 | **P0 新增** | | 冲刺版 ¥49.9/月 | Phase 1.5 |
| 每日一题推送 | 微信订阅消息推送面试题 | **P0 新增** | | 微信登录真实 appid 联调 | Phase 1 |
| 公司真题库 | 5 家头部公司专属面试题库 | P1 | | 生产环境部署 | Phase 1 |
| 连续打卡激励 | 7 天打卡解锁高级功能 | P1 | | AI 岗位专属题库 | Phase 2 |
| 会员系统 | ¥19.9/月 + ¥49.9/月 | P0 | | 技能缺口分析 | Phase 2 |
| 微信支付对接 | 微信支付接入 | P0 |
### 4.3 Phase 2 扩展(秋招前)
| 功能 | 描述 | 优先级 |
|------|------|--------|
| 简历诊断 | AI 分析简历问题 | P1 |
| AI 岗位专属题库 | AI 算法/大模型岗位面试题库 | P1 |
| 技能缺口分析 | 基于目标岗位分析技能差距 | P1 |
| AI 学习路径推荐 | 补齐技能缺口的学习路线 | P2 |
| 精选实习岗位 | 人工整理优质实习岗位 | P1 |
### 4.4 Phase 3 商业化(Q4
| 功能 | 描述 | 优先级 |
|------|------|--------|
| 高校就业办合作 | B 端订阅 + 批量管理 | P1 |
| 企业 HR 初筛 | AI 面试初筛工具 | P2 |
| 内推平台 | 企业发布岗位 + 内推佣金 | P2 |
| 真人导师点评 | 冲刺版专属 1v1 服务 | P2 |
--- ---
## 五、推广方案 ## 五、里程碑
### 5.1 免费渠道(立即启动) | 里程碑 | 时间 | 交付物 |
| 渠道 | 成本 | 可行性 | 说明 | |--------|------|--------|
|------|------|--------|------| | M0: 战略升级 | ✅ D1 | 文档 + 定价 |
| 微信公众号 | ¥0 | ✅✅✅ | 发校招面试技巧 + 产品入口 | | M0.5: 壁垒构建 | ✅ D7 | 数据飞轮 + 留存入围 |
| 知乎回答 | ¥0 | ✅✅✅ | "校招面试准备"类问题,长尾流量 | | M1: MVP 上线 | D14 | 小程序上线 + 内测 |
| 学校 BBS/贴吧 | ¥0 | ✅✅ | 各高校 BBS 发帖 | | M2: PMF 验证 | D30 | 100 用户 + 付费数据 |
| 豆瓣小组 | ¥0 | ✅✅ | 校招/求职类小组 | | M3: 秋招冲刺 | D90 | 1000+ 付费用户 |
### 5.2 付费渠道(秋招前投)
| 渠道 | 预算 | 预期 |
|------|------|------|
| 小红书 KOC | ¥1000-3000/月 | 精准触达应届生 |
| 抖音信息流 | ¥2000-5000/月 | 视频展示产品 |
| 求职公众号合作 | ¥0(资源置换) | 互相推广 |
--- ---
## 六、里程碑 ## 变更记录
| 里程碑 | 时间 | 交付物 | 成功标准 |
|--------|------|--------|----------|
| M0: 战略升级 | ✅ D1 | 文档体系更新 + 定价重构 | 已完成 |
| M0.5: 壁垒构建 | D7 | 进步轨迹 + 面经贡献 + 每日一题 | 功能可用 |
| M1: MVP 上线 | D14 | 小程序审核通过,内测启动 | 100 内测用户 |
| M2: PMF 验证 | D30 | 100 内测用户反馈 | 次日留存 >30% |
| M3: 付费上线 | D45 | 会员系统 + 微信支付 | 10+ 付费用户 |
| M4: 秋招冲刺 | D90 | 秋招旺季爆发推广 | 1000+ 付费用户 |
---
## 七、风险与应对
| 风险 | 影响 | 应对 |
|------|------|------|
| 用户获取成本高 | 推广预算有限 | 优先免费渠道;秋招前投小红书 ¥3000/月 |
| 留存率未达标 | 用户面完就走 | 进步轨迹 + 每日一题 + 打卡激励 |
| 竞品降价 | 价格优势被削弱 | 真题题库 + 数据飞轮 = 不可替代性 |
| 微信审核 | 突然下架 | 严格合规 + H5 备用方案 |
| AI 成本失控 | 利润被吃掉 | 限制免费版调用次数;监控告警 |
---
## 八、变更记录
| 日期 | 变更内容 | 操作者 | | 日期 | 变更内容 | 操作者 |
|------|----------|--------| |------|----------|--------|
| 2026-05-14 | 职引项目启动 | AI | | 2026-05-14 | 初版 | AI |
| 2026-06-01 | 重新定位:专注校招,¥9.9/月 | AI | | 2026-06-01 | 重新定位:专注校招 | AI |
| 2026-06-05 | **战略升级**:三层壁垒 + ¥19.9/¥49.9 定价 + B 端扩展 + 数据飞轮 | 小之 | | 2026-06-05 | 战略升级 | 小之 |
| 2026-06-09 | 同步代码状态,更新功能清单 | AI |
+119 -93
View File
@@ -1,8 +1,8 @@
# 职引项目 · 状态报告 v4.0 # 职引项目 · 状态报告 v4.1
> **项目版本**: v4.0 > **项目版本**: v4.2
> **更新时间**: 2026-06-05 17:13 > **更新时间**: 2026-06-09
> **项目状态**: 🚧 壁垒构建中(战略升级 v4.0 > **项目状态**: 🚀 Phase 0.5 壁垒构建完成 + 全量代码评审修复
--- ---
@@ -13,9 +13,9 @@
| 项目名称 | 职引(ZhiYin | | 项目名称 | 职引(ZhiYin |
| 定位 | 应届生/实习生 AI 面试教练 | | 定位 | 应届生/实习生 AI 面试教练 |
| 技术栈 | NestJS + MongoDB + Uni-App(Vue3) | | 技术栈 | NestJS + MongoDB + Uni-App(Vue3) |
| 定价 | 免费版 / ¥19.9月(成长版) / ¥49.9月(冲刺版) | | 定价 | 免费版 / ¥19.9/月(成长版) / ¥49.9/月(冲刺版) |
| AI 模型 | DeepSeek V4-Flash(主) + Step-3.5-Flash(备) | | AI 模型 | DeepSeek V4-Flash(主) + Step-3.5-Flash(备) |
| 后端模块 | user, interview, resume, member, payment, positions, ai, analyze, schemas, upload, admin | | 后端模块 | user, interview, resume, member, payment, positions, ai, analyze, upload, admin, email, progress, contribution, daily-question, schedule |
--- ---
@@ -23,120 +23,146 @@
| 模块 | 完成度 | 说明 | | 模块 | 完成度 | 说明 |
|------|------|------| |------|------|------|
| 后端 API | **95%** | 核心接口全部通过测试(10/10 | | 后端 API | **98%** | 核心 + Phase 0.5 接口全部实现并编译通过 |
| 前端页面 | **60%** | 核心页面骨架存在,UI 细节待完善 | | 前端页面 | **85%** | 16 个页面全部含真实 API 调用,有真实实现 |
| AI 面试模拟 | **90%** | 多轮对话 + 评分 + 报告,待联调优化 | | AI 面试模拟 | **95%** | 多轮对话 + 评分 + 报告 + 进度追踪 |
| 简历诊断 | **95%** | 已有完整代码,测试通过 | | 简历诊断/优化 | **95%** | 完整代码,文件上传 + AI 分析 + 下载 |
| 微信登录 | **70%** | 后端接口完备,前端待联调真实 appid | | 支付系统(微信) | **95%** | API v3 完整对接,含签名/解密/回调/生产密钥 |
| 会员系统 | **50%** | 后端数据模型存在,定价待更新(¥19.9/¥49.9),支付未打通 | | 会员系统 | **100%** | 成长版(¥19.9) + 冲刺版(¥49.9)完整实现,含权益扣减 |
| 生产部署 | **10%** | 配置文档齐,服务器未购买 | | 进步轨迹雷达图 | **100%** | 后端维度统计 + 前端雷达图/打卡日历 |
| 面经贡献系统 | **100%** | 贡献提交 + 公司题库自动去重/频次统计 |
| 每日一题 | **90%** | 读取 + 定时推送(早8点) + 微信订阅消息,缺模板ID配置 |
| 微信登录 | **70%** | 后端接口齐,前端待联调真实 appid |
| 生产部署 | **50%** | 服务器已购买,域名已配置,微信支付证书已就位,miniprogram-ci 编译上传脚本就绪 |
| 小程序审核 | **0%** | 类目已备案,未提交审核 | | 小程序审核 | **0%** | 类目已备案,未提交审核 |
--- ---
## 三、新增功能开发(Phase 0.5 壁垒构建) ## 三、功能完成明细
### 3.1 核心 AI 面试 (P0)
| 功能 | 后端 | 前端 | 状态 | | 功能 | 后端 | 前端 | 状态 |
|------|------|------|------| |------|------|------|------|
| 进步轨迹雷达图 | 🔨 开发中 | 🔨 开发中 | 今日启动 | | 岗位选择(20+) | ✅ | ✅ | **完成** |
| 面经贡献系统 | 🔨 开发中 | 🔨 开发中 | 今日启动 | | 多轮对话追问 | ✅ | ✅ | **完成** |
| 每日一题推送 | 🔨 开发中 | 🔨 开发中 | 今日启动 | | 实时反馈评分 | ✅ | ✅ | **完成** |
| 公司真题库 | 🔨 开发中 | 📋 规划中 | 数据结构设计 | | 面试报告(四维) | ✅ | ✅ | **完成** |
| 会员定价更新 | 🔨 开发中 | 🔨 开发中 | ¥19.9/¥49.9 | | 历史记录/统计 | ✅ | ✅ | **完成** |
| 微信支付对接 | 🔨 开发中 | 🔨 开发中 | 接口对接中 | | 使用次数限制 | ✅ | N/A | **完成** |
| 连续打卡(进步轨迹) | ✅ | ✅ | **完成** |
### 3.2 数据飞轮 (Phase 0.5)
| 功能 | 后端 | 前端 | 状态 |
|------|------|------|------|
| 面经贡献 | ✅ | ✅ | **完成** |
| 公司-岗位-题库映射 | ✅ | N/A | **完成** |
| 脱敏存储 | ✅ | N/A | **完成** |
| 题库自动扩充(去重+频次) | ✅ | N/A | **完成** |
### 3.3 留存入围 (Phase 0.5)
| 功能 | 后端 | 前端 | 状态 |
|------|------|------|------|
| 进步轨迹雷达图 | ✅ | ✅ | **完成** |
| 历史对比分析 | ✅ | ✅ | **完成** |
| 日历打卡视图 | N/A | ✅ | **完成** |
| 每日一题推送 | ✅ 定时推送(早8点) | ✅ 首页展示 | **完成**(缺微信模板ID |
### 3.4 用户系统
| 功能 | 后端 | 前端 | 状态 |
|------|------|------|------|
| 手机验证码登录 | ✅ | ✅ | **完成** |
| 邮箱验证码登录 | ✅ | ✅ | **完成** |
| 密码登录/注册 | ✅ | ✅ | **完成** |
| 微信静默登录 | ✅ 有接口 | ✅ 有调用 | ⚠️ 缺真实 appid |
| JWT 认证 | ✅ | ✅ | **完成** |
| 个人信息设置 | ✅ | ✅ | **完成** |
### 3.5 商业化
| 功能 | 后端 | 前端 | 状态 |
|------|------|------|------|
| 免费版额度(日2次/5轮) | ✅ | ✅ | **完成** |
| 成长版 ¥19.9/月 | ✅ | ✅ | **完成** |
| 冲刺版 ¥49.9/月(含权益扣减) | ✅ | ✅ | **完成** |
| 每日一题定时推送(微信订阅消息) | ✅ | N/A | **完成**(需配置模板ID |
| 微信支付 Native QR | ✅ | ✅ H5 | **完成** |
| 微信支付 JSAPI | ✅ | ✅ MP | **完成** |
| 支付回调/自动开会员 | ✅ | N/A | **完成** |
| 会员状态/套餐查询 | ✅ | ✅ | **完成** |
### 3.6 简历
| 功能 | 后端 | 前端 | 状态 |
|------|------|------|------|
| AI 简历诊断 | ✅ | ✅ | **完成** |
| AI 简历优化 | ✅ | ✅ | **完成** |
| 简历 CRUD | ✅ | ✅ | **完成** |
| 文件上传(PDF/图片) | ✅ | ✅ | **完成** |
| 结果下载(TXT/HTML) | N/A | ✅ | **完成** |
--- ---
## 四、已完成工作(100% ## 四、后端模块清单
### 4.1 后端核心 API | 模块 | 文件 | 状态 | 说明 |
| 测试项 | 状态 | 说明 | |------|------|------|------|
|--------|------|------| | `user` | controller + service + schema | ✅ | 手机/邮箱/密码/微信多种登录方式 |
| 发送验证码 | ✅ | 开发模式返回固定验证码 123456 | | `interview` | controller + service + schema | ✅ | AI 面试核心,含进度追踪调用 |
| 用户登录 | ✅ | 返回 JWT token 和用户信息 | | `ai` | module + service | ✅ | AI 模型调用封装(主/备切换) |
| 获取用户信息 | ✅ | 返回用户详情 | | `analyze` | controller + module + service | ✅ | 简历诊断/优化 |
| 简历诊断 | ✅ | AI 分析返回评分、问题列表、改进建议 | | `resume` | controller + service + schema | ✅ | 简历 CRUD |
| 简历优化 | ✅ | AI 优化返回优化后的简历内容 | | `member` | controller | ✅ | 会员套餐/状态/冲刺版权益扣减 |
| 创建模拟面试 | ✅ | 返回面试ID、首个问题、提示 | | `payment` | controller + service + schema | ✅ | 微信支付 v3 完整对接(生产密钥已配) |
| 回答面试问题 | ✅ | AI 给出反馈和下一个问题 | | `positions` | controller + schema | ✅ | 热门岗位 CRUD |
| 完成面试生成报告 | ✅ | 返回总分、强弱项、改进建议 | | `upload` | controller + module | ✅ | 文件上传 |
| 获取历史记录 | ✅ | 返回用户的所有面试记录 | | `admin` | controller + module | ✅ | 管理后台 |
| 获取用户统计 | ✅ | 返回面试次数、平均分数 | | `email` | module + service | ✅ | 邮件发送 |
| `progress` | controller + schema | ✅ | 进步轨迹四维统计 |
**测试通过率:100% (10/10)** | `contribution` | controller + schema (×2) | ✅ | 面经贡献 + 公司题库 |
| `daily-question` | controller + schema | ✅ | 读取 + 定时推送 @schedule |
### 4.2 前端核心页面 | `schedule` | module + service (×2) | ✅ | 每日一题早8点推送 + 微信token管理 |
- ✅ 首页(index.vue):面试、简历诊断、简历优化入口
- ✅ 登录页面(login.vue):支持手机验证码登录
- ✅ 模拟面试页面(interview.vue):支持多轮对话
- ✅ 历史记录页面(history.vue):查看历史记录
- ✅ 个人中心页面(user.vue):用户信息和管理
- ✅ 简历诊断页面(diagnosis.vue):AI 分析简历
- ✅ 简历优化页面(optimize.vue):AI 优化简历
### 4.3 上线配置
- ✅ 生产环境配置(.env.production
- ✅ 部署文档(docs/DEPLOYMENT.md
- ✅ 小程序上线检查清单(docs/WECHAT-CHECKLIST.md
--- ---
## 五、项目文件清单 ## 五、前端页面清单
### 后端 | 页面 | 路径 | 类型 | 状态 |
- `backend/.env.production` - 生产环境配置 |------|------|------|------|
- `backend/dist/` - 编译后的代码 | 首页 | index/index | Tab | ✅ 岗位/每日一题/功能入口 |
- `backend/src/modules/` - 11 个业务模块 | 登录 | login/login | 页面 | ✅ 5 种登录方式 + 注册 |
| 面试模拟 | interview/interview | 页面 | ✅ 多轮对话 + 计时 |
### 前端 | 面试报告 | report/report | 页面 | ✅ 评分/分析/全文回放 |
- `zhiyin-app/config/api.js` - API 配置 | 历史记录 | history/history | Tab | ✅ 筛选/统计/跳转报告 |
- `zhiyin-app/manifest.json` - 小程序配置(AI 深度合成类目已通过) | 个人中心 | user/user | Tab | ✅ 用户信息/统计/管理员入口 |
- `zhiyin-app/src/pages/` - 页面文件 | 会员中心 | member/member | 页面 | ✅ 套餐对比 + 支付流程 |
| 进步轨迹 | progress/progress | 页面 | ✅ 雷达图 + 打卡日历 |
### 文档 | 面经贡献 | contribute/contribute | 页面 | ✅ 表单提交 |
- `docs/PRODUCT-PLAN.md` - 产品规划 v4.0(✅ 已更新) | 简历优化 | resume/resume | 页面 | ✅ 诊断/优化/上传/下载 |
- `docs/ARCHITECTURE.md` - 架构文档 | 优化结果 | result/result | 页面 | ✅ 双模式结果展示 |
- `docs/FEATURE-LIST.md` - 功能清单 v4.0(✅ 已更新) | 实习搜索 | internship/internship | 页面 | ✅ 热门岗位列表 |
- `docs/ROADMAP.md` - 路线图 v4.0(✅ 已更新) | 管理后台 | admin/admin | 页面 | ✅ 仪表盘 |
- `docs/PROJECT-STATUS.md` - 状态报告(本文件) | 关于 | about/about | 页面 | ✅ |
- `docs/DEPLOYMENT.md` - 部署文档 | 用户协议 | agreement/agreement | 页面 | ✅ |
- `docs/WECHAT-CHECKLIST.md` - 小程序上线检查清单 | 隐私政策 | privacy/privacy | 页面 | ✅ |
### 测试
- `test-full.js` - 完整功能测试脚本
--- ---
## 六、下一步行动(按优先级) ## 六、技术债务
| # | 行动 | 负责 | 预计时间 |
|---|------|------|------|
| 1 | 进步轨迹雷达图后端 API | 小之 | 今天 |
| 2 | 面经贡献系统后端 API | 小之 | 今天 |
| 3 | 每日一题推送后端 API | 小之 | 今天 |
| 4 | 会员定价更新(¥19.9/¥49.9) | 小之 | 今天 |
| 5 | 前端页面完善(雷达图/贡献/会员) | 小之 | 本周 |
| 6 | 微信登录联调(真实 appid | lt | 本周 |
| 7 | 生产环境部署 | lt | 本周 |
| 8 | 小程序审核提交 | lt | 下周 |
---
## 七、技术债务
| 问题 | 影响 | 优先级 | | 问题 | 影响 | 优先级 |
|------|------|------| |------|------|------|
| 微信登录未用真实 appid 联调 | 无法真机测试 | P0 | | 微信登录未用真实 appid 联调 | 无法真机测试微信登录 | P0 |
| 前端 API 错误处理不够健壮 | 偶现白屏 | P1 | | 前端两套 API 调用方式(`uni.request` vs `apiService`) | 代码维护负担 | P2 |
| 前端无状态管理(Pinia) + 无组件复用 | 代码重复 | P2 |
| AI 调用无重试机制 | 偶发失败 | P1 | | AI 调用无重试机制 | 偶发失败 | P1 |
| 无单元测试 | 回归风险 | P2 | | 无单元测试 | 回归风险 | P2 |
| TypeScript strict mode 未开启 | 类型安全不足 | P2 |
--- ---
## 、变更记录 ## 、变更记录
| 日期 | 变更内容 | 操作者 | | 日期 | 变更内容 | 操作者 |
|------|----------|--------| |------|----------|--------|
| 2026-06-02 | 项目状态初版,测试 10/10 通过 | AI | | 2026-06-02 | 项目状态初版,测试 10/10 通过 | AI |
| 2026-06-05 | **战略升级**:文档重构 + 新增功能启动 + 真实状态评估 | 小之 | | 2026-06-05 | 战略升级:文档重构 + 新增功能启动 | 小之 |
| 2026-06-09 | 全面更新:Phase 0.5 功能实际已完成,修正完成度数据与模块清单 | AI |
| 2026-06-09 | 更新部署状态:服务器已购,域名 zhiyinwx.yzrcloud.cn / zhiyin.yzrcloud.cn 已配 | 小之 |
| 2026-06-09 | v4.2 冲刺版+每日推送+支付修复+全量代码评审 | AI |
+67 -76
View File
@@ -1,9 +1,9 @@
# 职引 · 产品路线图 v4.0 # 职引 · 产品路线图 v4.1
> 版本: v4.0 > **版本**: v4.1
> 日期: 2026-06-05 > **日期**: 2026-06-09
> 状态: 战略升级(竞争壁垒 + 盈利模型重构) > **状态**: Phase 0.5 壁垒构建完成,待上线
> 定位: 应届生/实习生 AI 面试教练 > **定位**: 应届生/实习生 AI 面试教练
--- ---
@@ -12,78 +12,70 @@
``` ```
Phase 0: 战略升级(✅ 已完成) Phase 0: 战略升级(✅ 已完成)
Phase 0.5: 壁垒构建(D1-7)→ 进步轨迹 + 面经贡献 + 每日一题 Phase 0.5: 壁垒构建(✅ 已完成)
Phase 1: MVP 上线(D7-14)→ 小程序审核 + 内测 + 微信支付 Phase 1: MVP 上线(D7-14)→ 小程序审核 + 内测 + 支付生产
Phase 1.5: 辅助功能 + 商业化(D14-30)→ PMF 验证 + 付费转化 Phase 1.5: 辅助功能 + 商业化(D14-30)→ PMF 验证
Phase 2: 增强 + 题库(D30-60)→ 秋招准备工作 Phase 2: 增强 + 题库(D30-60)→ 秋招准备
Phase 3: 商业化 + B 端(D60-90)→ 秋招爆发 + 收入增长 Phase 3: 商业化 + B 端(D60-90)→ 秋招爆发
``` ```
--- ---
## 二、Phase 0战略升级(✅ 已完成) ## 二、Phase 0: 战略升级(✅ 已完成)
**目标**: 重构竞争壁垒与盈利模型
**已完成**: **已完成**:
- [x] 定价重构:¥9.9 → ¥19.9/¥49.9 三段式 - [x] 定价重构:免费 + ¥19.9/月 两段式
- [x] 三层壁垒设计(数据飞轮 + 留存入围 + 合规信任) - [x] 三层壁垒设计(数据飞轮 + 留存入围 + 合规信任)
- [x] 收入来源多元化(C 端 + B 端 + 内容 + 人才匹配) - [x] 收入来源多元化策略
- [x] 文档体系全面更新 - [x] 文档体系全面更新
- [x] 清理冗余文档(DEVELOPMENT-PLAN.md、重复 PROJECT-STATUS.md
--- ---
## 三、Phase 0.5:壁垒构建(D1-7,正在执行 ## 三、Phase 0.5:壁垒构建(✅ 已完成
### 3.1 数据飞轮功能 ### 3.1 数据飞轮功能
| 功能 | 描述 | 状态 | | 功能 | 描述 | 状态 |
|------|------|------| |------|------|------|
| 面经贡献系统 | 面试后可贡献公司/岗位信息,脱敏存入题库 | 🔨 开发中 | | 面经贡献系统 | 面试后可贡献公司/岗位信息 | ✅ 完成 |
| 公司-岗位-题库映射 | 数据结构设计,四维映射 | 🔨 开发中 | | 公司-岗位-题库映射 | 四维映射 + 自动去重 + 频次统计 | ✅ 完成 |
| 题库自动扩充 | 基于用户贡献自动生成题目 | 📋 规划中 | | 题库自动扩充 | 基于用户贡献自动积累 | ✅ 完成 |
### 3.2 留存入围功能 ### 3.2 留存入围功能
| 功能 | 描述 | 状态 | | 功能 | 描述 | 状态 |
|------|------|------| |------|------|------|
| 进步轨迹雷达图 | 四维能力可视化 + 历史对比 | 🔨 开发中 | | 进步轨迹雷达图 | 四维能力可视化 + 打卡日历 | ✅ 完成 |
| 日历打卡视图 | 面试频率可视化,连续打卡激励 | 🔨 开发中 | | 历史对比分析 | 最近面试趋势 | ✅ 完成 |
| 每日一题 Push | 微信订阅消息推送面试题 | 🔨 开发中 | | 每日一题 | API + 首页展示 | ⚠️ 缺定时推送 |
### 3.3 会员系统重构 ### 3.3 会员系统重构
| 功能 | 描述 | 状态 | | 功能 | 描述 | 状态 |
|------|------|------| |------|------|------|
| 定价更新 | ¥19.9/月 成长版 + ¥49.9/月 冲刺版 | 🔨 开发中 | | 定价更新 | ¥19.9/月 成长版 | ✅ 完成 |
| 会员权益对比 | 三版对比展示页面 | 🔨 开发中 | | 会员权益对比 | 三版对比展示 | ✅ 完成 |
| 微信支付对接 | 微支付接入与测试 | 🔨 开发中 | | 微信支付对接 | Native + JSAPI 支付 | ✅ 完成 |
--- ---
## 四、Phase 1MVP 上线(D7-14 ## 四、Phase 1MVP 上线(D7-14,当前阶段
### 4.1 上线准备 ### 4.1 上线准备
| 任务 | 描述 | 状态 | | 任务 | 描述 | 状态 |
|------|------|------| |------|------|------|
| 前端页面完善 | 所有 P0 页面 UI 完成 | ⏳ 待开始 | | 前端页面完善 | 16 个页面全部就绪 | ✅ 完成 |
| 微信登录联调 | 真实 appid 验证 | ⏳ 待开始 | | 微信登录联调 | 真实 appid 验证 | ⏳ 待进行 |
| 生产环境部署 | 服务器 + MongoDB + Nginx + PM2 | ⏳ 待开始 | | 移除开发绕过 | `member/pay` 直接激活 | ⏳ 待进行 |
| 小程序审核提交 | 资质齐全,可立即提交 | ⏳ 待开始 | | 生产环境部署 | 服务器 + MongoDB + Nginx + PM2 | ✅ 服务器已购,域名已配(zhiyinwx → API:3006zhiyin.yzrcloud → H5 静态目录) |
| 内测版发布 | 邀请码方式,100 人内测 | ⏳ 待开始 | | 小程序审核提交 | 资质齐全 | ⏳ 待进行 |
| 内测版发布 | 邀请码方式,100 人内测 | ⏳ 待进行 |
### 4.2 内测指标 ### 4.2 内测指标
- **关键指标**: 次日留存 > 30%7 日留存 > 15% - **关键指标**: 次日留存 > 30%7 日留存 > 15%
- **反馈收集**: 问卷 + 访谈 - **反馈收集**: 问卷 + 访谈
- **如果达标**: 继续 Phase 1.5 - **如果达标**: 继续 Phase 1.5
- **如果不达标**: 复盘原因,调整后继续
--- ---
@@ -91,11 +83,11 @@ Phase 3: 商业化 + B 端(D60-90)→ 秋招爆发 + 收入增长
| 功能 | 描述 | 优先级 | | 功能 | 描述 | 优先级 |
|------|------|--------| |------|------|--------|
| 简历诊断 | 复用已有代码,AI 分析简历 | P1 | | 每日一题定时推送 | 微信订阅消息推送 | P0 |
| AI 岗位专属题库 | AI 算法/大模型岗位面试题库 | P1 | | 冲刺版 ¥49.9/月 | 高客单价 | P1 |
| 连续打卡激励 | 7 天打卡解锁高级功能 | P1 | | 连续打卡激励 | 7 天解锁高级报告 | P1 |
| 付费转化验证 | 100 内测用户 → 10+ 付费用户 | P0 | | 付费转化验证 | 100 内测用户 → 10+ 付费 | P0 |
| PMF 决策 | 转化率 > 5% → 继续< 5% → 复盘 | P0 | | PMF 决策 | 转化率 > 5% → 继续 | P0 |
--- ---
@@ -111,12 +103,12 @@ Phase 3: 商业化 + B 端(D60-90)→ 秋招爆发 + 收入增长
| 百度 | 技术 + AI 岗位 | 📋 规划中 | | 百度 | 技术 + AI 岗位 | 📋 规划中 |
### 6.2 增强功能 ### 6.2 增强功能
| 功能 | 描述 | 优先级 | | 功能 | 优先级 |
|------|------|--------| |------|--------|
| 技能缺口分析 | 基于目标岗位分析技能差距 | P1 | | 技能缺口分析 | P1 |
| AI 学习路径推荐 | 免费资源整合推荐 | P2 | | AI 学习路径推荐 | P2 |
| 更多岗位覆盖 | 扩展到 50+ 校招热门岗位 | P1 | | 50+ 校招热门岗位 | P1 |
| 精选实习岗位 | 人工整理优质实习(秋招前强化) | P1 | | 精选实习岗位 | P1 |
--- ---
@@ -128,15 +120,15 @@ Phase 3: 商业化 + B 端(D60-90)→ 秋招爆发 + 收入增长
- 高校合作启动 2-3 所 - 高校合作启动 2-3 所
### 7.2 B 端启动 ### 7.2 B 端启动
| 功能 | 描述 | 优先级 | | 功能 | 优先级 |
|------|------|--------| |------|--------|
| 高校就业办合作 | B 端订阅 + 批量管理 | P1 | | 高校就业办合作 | P1 |
| 企业内推服务 | 帮助企业筛选简历 | P2 | | 企业内推服务 | P2 |
| 真人导师点评 | 冲刺版专属 1v1 服务 | P2 | | 真人导师点评 | P2 |
### 7.3 秋招旺季推广(9-11月) ### 7.3 秋招旺季推广(9-11月)
- 小红书 KOC 投放 ¥3000/月 - 小红书 KOC 投放 ¥3000/月
- 微信公众号密集推送面试技巧 - 微信公众号推送面试技巧
- 知乎回答"秋招如何准备"类问题 - 知乎回答"秋招如何准备"类问题
- 学校 BBS/贴吧精准发帖 - 学校 BBS/贴吧精准发帖
@@ -146,12 +138,12 @@ Phase 3: 商业化 + B 端(D60-90)→ 秋招爆发 + 收入增长
| 里程碑 | 时间 | 交付物 | 成功标准 | | 里程碑 | 时间 | 交付物 | 成功标准 |
|--------|------|--------|----------| |--------|------|--------|----------|
| M0: 战略升级 | ✅ D1 | 文档体系 + 定价重构 | 已完成 | | M0: 战略升级 | ✅ D1 | 文档 + 定价 | 已完成 |
| M0.5: 壁垒构建 | D7 | 进步轨迹 + 面经贡献 + 每日一题 | 功能可用 | | M0.5: 壁垒构建 | D7 | 进步轨迹 + 面经贡献 + 每日一题 | 功能可用 |
| M1: MVP 上线 | D14 | 小程序审核通过,内测启动 | 100 内测用户 | | M1: MVP 上线 | D14 | 小程序审核通过,内测启动 | 100 内测用户 |
| M2: PMF 验证 | D30 | 100 用户反馈 + 付费数据 | 转化率 > 5% | | M2: PMF 验证 | D30 | 100 用户反馈 | 转化率 > 5% |
| M3: 付费上线 | D45 | 会员系统 + 微信支付 | 50+ 付费用户 | | M3: 付费上线 | D45 | 冲刺版 + 定时推送 | 50+ 付费用户 |
| M4: 秋招冲刺 | D90 | 秋招爆发推广 | 1000+ 付费用户 | | M4: 秋招冲刺 | D90 | 秋招推广 | 1000+ 付费用户 |
--- ---
@@ -159,16 +151,16 @@ Phase 3: 商业化 + B 端(D60-90)→ 秋招爆发 + 收入增长
``` ```
2026 年校招日历: 2026 年校招日历:
6-8 月:暑假实习搜索高峰 + 秋招准备(打磨产品 + 积累种子用户) 6-8 月:暑假实习 + 秋招准备(打磨产品 + 积累种子用户)
9-11 月:秋招(主战场,全力推广冲刺) 9-11 月:秋招(主战场,全力推广冲刺)
12-2 月:寒假 + 春招准备(B 端合作拓展) 12-2 月:寒假 + 春招准备(B 端合作拓展)
关键时间点: 关键时间点:
- 现在(6月5日):壁垒构建 + MVP 完善 6月9日:壁垒构建完成,Phase 0.5 交付
- 6月15日:MVP 上线,内测启动 6月15日:MVP 上线,内测启动
- 7月1日:PMF 验证,付费转化 7月1日:PMF 验证,付费转化
- 8月1日:Phase 2 完成,准备秋招 8月1日:Phase 2 完成,准备秋招
- 9月1日:秋招旺季,全力推广 9月1日:秋招旺季,全力推广
``` ```
--- ---
@@ -177,20 +169,19 @@ Phase 3: 商业化 + B 端(D60-90)→ 秋招爆发 + 收入增长
| 风险 | 影响 | 应对 | | 风险 | 影响 | 应对 |
|------|------|------| |------|------|------|
| 用户获取成本高 | 推广预算有限 | 免费渠道优先;秋招前投小红书 | | 用户获取成本高 | 预算有限 | 免费渠道优先 |
| 留存率未达标 | 用户面完就走 | 进步轨迹 + 每日一题 + 打卡激励 | | 留存率未达标 | 用户面完就走 | 进步轨迹 + 每日一题 + 打卡 |
| 竞品降价 | 价格优势被削弱 | 真题题库 + 数据飞轮 = 不可替代 | | 竞品降价 | 优势被削弱 | 数据飞轮不可替代 |
| 微信审核风险 | 小程序下架 | 严格合规 + H5 备用方案 | | 微信审核风险 | 小程序下架 | 严格合规 + H5 备用 |
| 暑假淡季(6-8月) | 用户活跃度下降 | 强化实习搜索,衔接秋招 | | AI 成本失控 | 利润被吃掉 | 限制免费调用 + 监控告警 |
| AI 成本失控 | 利润被吃掉 | 限制免费调用;监控告警 |
| PMF 验证失败 | 浪费时间 | Week 4 关键决策,及时 pivot |
--- ---
## 十一、变更记录 ## 变更记录
| 日期 | 变更内容 | 操作者 | | 日期 | 变更内容 | 操作者 |
|------|----------|--------| |------|----------|--------|
| 2026-05-14 | 路线图初版 | AI | | 2026-05-14 | 路线图初版 | AI |
| 2026-06-01 | 重新规划:专注校招,¥9.9/月,MVP 2 周 | AI | | 2026-06-01 | 重新规划:专注校招 | AI |
| 2026-06-05 | **战略升级**:三层壁垒 + ¥19.9/¥49.9 + B 端 + 数据飞轮 + 新里程碑 | 小之 | | 2026-06-05 | 战略升级:三层壁垒 + 新定价 | 小之 |
| 2026-06-09 | Phase 0.5 标记完成,调整后续里程碑时间 | AI |
+41 -80
View File
@@ -1,10 +1,11 @@
# 职引 - 微信小程序上线检查清单 # 职引 - 微信小程序上线检查清单
> **用途**:提交微信审核前,逐项检查,确保一次通过 > **用途**:提交微信审核前,逐项检查,确保一次通过
> **最后更新**: 2026-06-09
--- ---
## 一、资质与合规 ## 一、资质与合规
### 1.1 账号资质 ### 1.1 账号资质
- [ ] 已完成微信认证(企业/个体工商户) - [ ] 已完成微信认证(企业/个体工商户)
@@ -12,137 +13,97 @@
- [ ] 已配置客服(用于审核人员测试登录) - [ ] 已配置客服(用于审核人员测试登录)
### 1.2 隐私合规 ### 1.2 隐私合规
- [ ] 《隐私政策》完整,包含: - [ ] 《隐私政策》完整
- [ ] 收集的个人信意类型(手机号、简历内容)
- [ ] 信息用途径(AI 分析、面试记录)
- [ ] 用户权利(查询、删除、注销)
- [ ] 联系方系(邮箱/电话)
- [ ] 《用户协议》完整 - [ ] 《用户协议》完整
- [ ] 小程序内可访问隐私政策和用户协议 - [ ] 小程序内可访问隐私政策和用户协议
- [ ] 首次收集个人信息前弹窗征得用户同意 - [ ] 首次收集个人信息前弹窗征得同意
- [ ] 提供用户注销账号功能 - [ ] 提供用户注销账号功能
### 1.3 内容安全 ### 1.3 内容安全
- [ ] AI 生成内容有标识("由 AI 生成" - [ ] AI 生成内容有标识("由 AI 生成"
- [ ] 用户输入内容经过敏感词过滤 - [ ] 用户输入内容经过敏感词过滤
- [ ] 简历诊断/优化结果不包含违法违规内容
--- ---
## 二、功能完整性 ## 二、功能完整性
### 2.1 核心流程 ### 2.1 核心流程
- [ ] 微信登录流程顺畅(授权 → 获取手机号 → 登录成功) - [ ] 微信登录流程顺畅(授权 → 登录成功)
- [ ] 手机验证码登录流程顺畅 - [ ] 手机验证码登录流程顺畅
- [ ] 简历诊断功能正常(上传分析 → 查看结果 - [ ] AI 模拟面试流程正常(选岗位问答 → 查看报告
- [ ] 简历优化功能正常(上传 → 优化 → 复制/下载)
- [ ] 模拟面试功能正常(选择岗位 → 开始面试 → 问答 → 查看报告)
- [ ] 面试历史记录正常显示 - [ ] 面试历史记录正常显示
- [ ] 个人中心信息可修改 - [ ] 进步轨迹雷达图正常显示
- [ ] 面经贡献功能正常(填写 → 提交)
- [ ] 简历诊断/优化功能正常(上传 → 分析 → 结果)
- [ ] 会员中心展示正常
- [ ] 个人中心信息可查看
### 2.2 异常场景 ### 2.2 异常场景
- [ ] 网络异常时有友好提示 - [ ] 网络异常时有友好提示
- [ ] AI 服务不可用时有降级方案mock 数据) - [ ] AI 服务不可用时有降级方案
- [ ] 用户未登录时引导登录 - [ ] 用户未登录时引导登录
- [ ] 表单验证错误提示清晰 - [ ] 表单验证错误提示清晰
--- ---
## 三、技术与性能 ## 三、技术与性能
### 3.1 接口与域名 ### 3.1 域名配置
- [ ] 所有 API 域名已在微信公众平台 → 开发 → 开发管理 → 开发设置 → 服务器域名】中配置 - [ ] 所有 API 域名已在微信公众平台配置
- [ ] request 合法域名:`https://api.yourdomain.com` - [ ] request 合法域名:`https://zhiyinwx.yzrcloud.cn`
- [ ] socket 合法域名:(如适用) - [ ] uploadFile 合法域名:`https://zhiyinwx.yzrcloud.cn`
- [ ] uploadFile 合法域名:(如适用) - [ ] 所有域名使用 HTTPS
- [ ] downloadFile 合法域名:(如适用) - [ ] 域名已 ICP 备案
- [ ] 所有域名使用 **HTTPS**(必须!)
- [ ] 域名已备案(ICP 备案)
### 3.2 性能 ### 3.2 性能
- [ ] 首屏加载时间 < 2s - [ ] 首屏加载时间 < 2s
- [ ] 页面切换流畅,无卡顿 - [ ] 页面切换流畅,无卡顿
- [ ] 图片资源已压缩 - [ ] 没有未使用的 console.log
- [ ] 没有未使用的 console.log(生产环境)
### 3.3 兼容性 ### 3.3 兼容性
- [ ] 微信开发者工具测试通过(不低于基础库 2.25.0 - [ ] 微信开发者工具测试通过(基础库 2.25.0+
- [ ] 真机测试通过(iOS + Android 各一款 - [ ] 真机测试通过(iOS + Android
- [ ] 不同屏幕尺寸适配正常
--- ---
## 四、用户体验 ## 四、用户体验
### 4.1 界面
- [ ] 所有文字无错别字 - [ ] 所有文字无错别字
- [ ] 按钮状态清晰(可点击/不可点击) - [ ] 按钮状态清晰(可点击/不可点击)
- [ ] 加载中有 loading 提示 - [ ] 加载中有 loading 提示
- [ ] 空状态有引导提示(如:暂无面试记录) - [ ] 空状态有引导提示
- [ ] 弹窗/提示框内容准确
### 4.2 交互
- [ ] 按钮点击有反馈(振动或动效)
- [ ] 下拉刷新正常 - [ ] 下拉刷新正常
- [ ] 上拉加载更多正常
- [ ] 表单输入体验良好(自动聚焦、键盘类型匹配)
--- ---
## 五、运营准备 ✅ ## 五、审核准备
- [ ] 准备测试账号(审核人员体验用)
### 5.1 基础信息 - [ ] 准备功能说明文档
- [ ] 小程序名称合规(不含有诱导、夸大宣传) - [ ] 提供客服联系方式
- [ ] 简介清晰(20 字以内概括核心功能)
- [ ] 类目标签准确(教育 > 职业技能培训)
- [ ] 头像/封面图清晰、合规
### 5.2 审核辅助
- [ ] 准备测试账号(如审核人员需要登录体验)
- [ ] 准备功能说明文档(复杂功能可录制演示视频)
- [ ] 提供客服联系方式(审核期间保持畅通)
--- ---
## 六、提交审核前最后确认 ## 六、提交前最后确认
- [ ] 后端所有接口测试通过
- [ ] 所有功能已测试通过(使用 [test-full.js](./test-full.js) 跑一遍) - [ ] `member/pay` 开发绕过已移除
- [ ] 版本号已更新 - [ ] 版本号已更新
- [ ] 项目备注已填写(说明本次更新内容)
- [ ] 已备份当前代码(git tag - [ ] 已备份当前代码(git tag
- [ ] 已通知相关人员(客服、运营)
--- ---
## 七、常见审核被拒原因 ⚠️ ## 七、常见审核被拒原因
### 7.1 隐私不合规 ### AI 类目未审批
**原因**:未明示收集使用个人信息的目的、方式和范围。 提前申请 AI 深度合成类目(已通过)。
**解决**:完善隐私政策,首次使用时弹窗征得同意。
### 7.2 AI 类目未审批 ### 功能不完整
**原因**:涉及 AI 对话/生成内容,但未申请 AI 深度合成类目。 审核人员无法完成核心流程 → 提供测试账号。
**解决**:提前申请类目审批,或下架 AI 相关功能后再提交审核。
### 7.3 功能不完整 ### 内容不安全
**原因**:审核人员无法完成核心流程(如无法登录、无法提交订单)。 AI 输出增加"由 AI 生成"标识 + 敏感词过滤。
**解决**:提供测试账号,确保核心流程顺畅。
### 7.4 内容不安全
**原因**:AI 生成内容可能包含违规信息。
**解决**:加强内容审核,增加敏感词过滤,AI 输出增加"由 AI 生成"标识。
--- ---
## 八、审核通过后 ## 八、审核通过后
- [ ] 及时发布上线 - [ ] 及时发布上线
- [ ] 监控线上错误日志 - [ ] 监控线上错误日志
- [ ] 收集用户反馈 - [ ] 收集用户反馈
- [ ] 准备下次迭代
---
**最后更新**2026-06-02
> 💡 **提示**:每次提交审核前,建议打印此清单,逐项勾选,确保不遗漏。
+12565 -66
View File
File diff suppressed because it is too large Load Diff
+3
View File
@@ -12,6 +12,8 @@
"@dcloudio/uni-components": "3.0.0-4060620250520001", "@dcloudio/uni-components": "3.0.0-4060620250520001",
"@dcloudio/uni-h5": "3.0.0-4060620250520001", "@dcloudio/uni-h5": "3.0.0-4060620250520001",
"@dcloudio/uni-mp-weixin": "3.0.0-4060620250520001", "@dcloudio/uni-mp-weixin": "3.0.0-4060620250520001",
"@esbuild/linux-x64": "^0.28.0",
"@rollup/rollup-linux-x64-gnu": "^4.61.1",
"pinia": "^2.1.7", "pinia": "^2.1.7",
"uqrcodejs": "^4.0.7", "uqrcodejs": "^4.0.7",
"vue": "^3.4.21" "vue": "^3.4.21"
@@ -21,6 +23,7 @@
"@dcloudio/uni-automator": "3.0.0-4060620250520001", "@dcloudio/uni-automator": "3.0.0-4060620250520001",
"@dcloudio/uni-cli-shared": "3.0.0-4060620250520001", "@dcloudio/uni-cli-shared": "3.0.0-4060620250520001",
"@dcloudio/vite-plugin-uni": "3.0.0-4060620250520001", "@dcloudio/vite-plugin-uni": "3.0.0-4060620250520001",
"miniprogram-ci": "^2.1.31",
"sass": "^1.70.0", "sass": "^1.70.0",
"typescript": "^5.3.0", "typescript": "^5.3.0",
"vite": "^5.2.0" "vite": "^5.2.0"
+39
View File
@@ -0,0 +1,39 @@
const ci = require('miniprogram-ci')
const path = require('path')
const projectPath = path.resolve(__dirname, '../dist/build/mp-weixin')
const privateKeyPath = path.resolve('/root/opencode-workspace/微信小程序参数/宇之然AI磁场appid/private.wxf466b3c3bc411ffc.key')
const appid = 'wxf466b3c3bc411ffc'
async function main() {
const project = new ci.Project({
appid,
type: 'miniProgram',
projectPath,
privateKeyPath,
ignores: ['node_modules/**/*'],
})
console.log(`Uploading from: ${projectPath}`)
console.log(`AppID: ${appid}`)
// Dry-run mode for testing — set raw false to actually upload
// For testing, we do a preview first
try {
const previewResult = await ci.preview({
project,
desc: '预览测试',
setting: {
es6: true,
minify: false,
},
})
console.log('Preview success!')
console.log('Preview result:', JSON.stringify(previewResult, null, 2))
} catch (err) {
console.error('Preview failed:', err.message)
process.exit(1)
}
}
main()
+9 -1
View File
@@ -51,6 +51,7 @@ export const API_ENDPOINTS = {
ANALYZE: { ANALYZE: {
DIAGNOSIS: '/analyze/diagnosis', DIAGNOSIS: '/analyze/diagnosis',
OPTIMIZE: '/analyze/optimize', OPTIMIZE: '/analyze/optimize',
SKILLS_GAP: '/analyze/skills-gap',
}, },
RESUME: { RESUME: {
CREATE: '/resume/create', CREATE: '/resume/create',
@@ -71,13 +72,20 @@ export const API_ENDPOINTS = {
MEMBER: { MEMBER: {
PLANS: '/member/plans', PLANS: '/member/plans',
STATUS: '/member/status', STATUS: '/member/status',
CREATE_ORDER: '/member/create-order',
PAY: '/member/pay', PAY: '/member/pay',
SPRINT_DEDUCT: '/member/sprint/deduct',
}, },
DAILY_QUESTION: { DAILY_QUESTION: {
TODAY: '/daily-question', TODAY: '/daily-question',
BY_POSITION: (position: string) => `/daily-question/position/${position}`, BY_POSITION: (position: string) => `/daily-question/position/${position}`,
}, },
PAYMENT: {
CREATE: '/payment/create',
JSAPI: '/payment/jsapi',
QUERY: '/payment/query',
CHECK: (outTradeNo: string) => `/payment/check/${outTradeNo}`,
ACTIVATE: '/payment/activate',
},
} as const } as const
const API_HOST = typeof window !== 'undefined' && window.location.hostname !== 'localhost' && window.location.hostname !== '127.0.0.1' const API_HOST = typeof window !== 'undefined' && window.location.hostname !== 'localhost' && window.location.hostname !== '127.0.0.1'
+1
View File
@@ -6,6 +6,7 @@
{ "path": "pages/member/member", "style": { "navigationBarTitleText": "会员中心" } }, { "path": "pages/member/member", "style": { "navigationBarTitleText": "会员中心" } },
{ "path": "pages/progress/progress", "style": { "navigationBarTitleText": "进步轨迹" } }, { "path": "pages/progress/progress", "style": { "navigationBarTitleText": "进步轨迹" } },
{ "path": "pages/contribute/contribute", "style": { "navigationBarTitleText": "面经分享" } }, { "path": "pages/contribute/contribute", "style": { "navigationBarTitleText": "面经分享" } },
{ "path": "pages/company-bank/bank", "style": { "navigationBarTitleText": "公司真题库" } },
{ "path": "pages/login/login", "style": { "navigationBarTitleText": "登录" } }, { "path": "pages/login/login", "style": { "navigationBarTitleText": "登录" } },
{ "path": "pages/history/history", "style": { "navigationBarTitleText": "面试记录" } }, { "path": "pages/history/history", "style": { "navigationBarTitleText": "面试记录" } },
{ "path": "pages/user/user", "style": { "navigationBarTitleText": "我的" } }, { "path": "pages/user/user", "style": { "navigationBarTitleText": "我的" } },
+197
View File
@@ -0,0 +1,197 @@
<template>
<view class="page">
<!-- 搜索栏 -->
<view class="search-bar">
<input class="search-input" v-model="keyword" placeholder="搜索公司名称..." @confirm="searchCompany" />
<button class="search-btn" @tap="searchCompany">搜索</button>
</view>
<!-- 热门公司 -->
<view class="section" v-if="!searching">
<view class="section-title">热门公司题库</view>
<view class="company-grid">
<view
class="company-card"
v-for="c in hotCompanies"
:key="c.name"
@tap="selectCompany(c.name)"
>
<view class="company-name">{{ c.name }}</view>
<view class="company-count">{{ c.positions }} 个岗位</view>
</view>
</view>
</view>
<!-- 搜索结果岗位列表 -->
<view class="section" v-if="selectedCompany && !loadingPositions">
<view class="section-title-row">
<text class="section-title">{{ selectedCompany }} - 岗位列表</text>
<text class="back-link" @tap="selectedCompany = ''">返回</text>
</view>
<view class="position-list">
<view
class="position-item"
v-for="p in positions"
:key="p.position"
@tap="selectPosition(p.position)"
>
<view class="position-name">{{ p.position }}</view>
<view class="position-meta">{{ p.questionCount }} · {{ p.contributionCount }} 人贡献</view>
</view>
<view class="empty-state" v-if="positions.length === 0">
<text>暂无该公司的面经数据</text>
<text class="sub-text">成为第一个贡献者吧</text>
</view>
</view>
</view>
<!-- 题目列表 -->
<view class="section" v-if="selectedPosition && !loadingQuestions">
<view class="section-title-row">
<text class="section-title">{{ selectedCompany }} · {{ selectedPosition }}</text>
<text class="back-link" @tap="selectedPosition = ''">返回</text>
</view>
<view class="question-list">
<view class="question-item" v-for="(q, i) in questions" :key="i">
<view class="q-header">
<text class="q-num">#{{ i + 1 }}</text>
<text class="q-tag">{{ q.type === 'technical' ? '技术' : '行为' }}</text>
<text class="q-diff">{{ difficultyLabel(q.difficulty) }}</text>
<text class="q-freq">{{ q.frequency }} 次提及</text>
</view>
<view class="q-content">{{ q.content }}</view>
<view class="q-tags" v-if="q.tags && q.tags.length">
<text class="tag" v-for="t in q.tags" :key="t">{{ t }}</text>
</view>
<view class="q-answer" v-if="q.referenceAnswer">
<text class="answer-label">参考思路</text>
<text class="answer-text">{{ q.referenceAnswer }}</text>
</view>
</view>
<view class="empty-state" v-if="questions.length === 0">
<text>暂无题目数据</text>
</view>
</view>
</view>
<!-- 加载态 -->
<view class="loading" v-if="loadingPositions || loadingQuestions">
<text>加载中...</text>
</view>
</view>
</template>
<script>
import { api } from '../../config'
const HOT_COMPANIES = [
{ name: '腾讯', positions: 5 },
{ name: '字节跳动', positions: 4 },
{ name: '阿里巴巴', positions: 5 },
{ name: '美团', positions: 3 },
{ name: '百度', positions: 4 },
{ name: '京东', positions: 3 },
{ name: '网易', positions: 3 },
{ name: '小红书', positions: 2 },
]
export default {
data() {
return {
keyword: '',
searching: false,
hotCompanies: HOT_COMPANIES,
selectedCompany: '',
selectedPosition: '',
positions: [],
questions: [],
loadingPositions: false,
loadingQuestions: false,
}
},
methods: {
difficultyLabel(d) {
const map = { junior: '简单', medium: '中等', senior: '困难' }
return map[d] || d || '中等'
},
async searchCompany() {
const kw = this.keyword.trim()
if (!kw) return
this.selectedCompany = kw
this.selectedPosition = ''
await this.loadPositions(kw)
},
async selectCompany(name) {
this.selectedCompany = name
this.keyword = name
this.selectedPosition = ''
await this.loadPositions(name)
},
async loadPositions(company) {
this.loadingPositions = true
const token = uni.getStorageSync('token') || ''
const header = token ? { 'Authorization': `Bearer ${token}` } : {}
try {
const res = await uni.request({ url: api(`/contribution/company/${encodeURIComponent(company)}`), method: 'GET', header })
this.positions = res.data || []
} catch (e) {
this.positions = []
}
this.loadingPositions = false
},
async selectPosition(position) {
this.selectedPosition = position
await this.loadQuestions(this.selectedCompany, position)
},
async loadQuestions(company, position) {
this.loadingQuestions = true
const token = uni.getStorageSync('token') || ''
const header = token ? { 'Authorization': `Bearer ${token}` } : {}
try {
const res = await uni.request({
url: api(`/contribution/company/${encodeURIComponent(company)}/position/${encodeURIComponent(position)}`),
method: 'GET',
header,
})
this.questions = res.data?.questions || []
} catch (e) {
this.questions = []
}
this.loadingQuestions = false
},
},
}
</script>
<style scoped>
.page { padding: 20rpx; min-height: 100vh; background: #f5f6f7; }
.search-bar { display: flex; gap: 20rpx; margin-bottom: 30rpx; }
.search-input { flex: 1; height: 80rpx; background: #fff; border-radius: 40rpx; padding: 0 30rpx; font-size: 28rpx; }
.search-btn { height: 80rpx; line-height: 80rpx; padding: 0 40rpx; background: #4F46E5; color: #fff; border-radius: 40rpx; font-size: 28rpx; }
.section { margin-bottom: 30rpx; background: #fff; border-radius: 16rpx; padding: 30rpx; }
.section-title { font-size: 32rpx; font-weight: 600; margin-bottom: 20rpx; display: block; }
.section-title-row { display: flex; justify-content: space-between; align-items: center; margin-bottom: 20rpx; }
.back-link { color: #4F46E5; font-size: 26rpx; }
.company-grid { display: grid; grid-template-columns: repeat(4, 1fr); gap: 20rpx; }
.company-card { background: #F3F0FF; border-radius: 12rpx; padding: 24rpx; text-align: center; }
.company-name { font-size: 28rpx; font-weight: 500; color: #333; }
.company-count { font-size: 22rpx; color: #999; margin-top: 8rpx; }
.position-item { padding: 24rpx; border-bottom: 1rpx solid #f0f0f0; }
.position-name { font-size: 28rpx; font-weight: 500; color: #333; }
.position-meta { font-size: 24rpx; color: #999; margin-top: 8rpx; }
.question-item { padding: 24rpx; border-bottom: 1rpx solid #f0f0f0; }
.q-header { display: flex; align-items: center; gap: 12rpx; margin-bottom: 12rpx; }
.q-num { font-size: 24rpx; color: #4F46E5; font-weight: 600; }
.q-tag { font-size: 22rpx; background: #E8F5E9; color: #2E7D32; padding: 2rpx 12rpx; border-radius: 6rpx; }
.q-diff { font-size: 22rpx; background: #FFF3E0; color: #E65100; padding: 2rpx 12rpx; border-radius: 6rpx; }
.q-freq { font-size: 22rpx; color: #999; margin-left: auto; }
.q-content { font-size: 28rpx; color: #333; line-height: 1.6; }
.q-tags { display: flex; flex-wrap: wrap; gap: 8rpx; margin-top: 12rpx; }
.tag { font-size: 22rpx; background: #F3F0FF; color: #4F46E5; padding: 4rpx 16rpx; border-radius: 20rpx; }
.q-answer { margin-top: 16rpx; padding: 16rpx; background: #F8F9FA; border-radius: 8rpx; }
.answer-label { font-size: 24rpx; color: #4F46E5; font-weight: 500; }
.answer-text { font-size: 26rpx; color: #555; line-height: 1.6; }
.empty-state { text-align: center; padding: 60rpx 0; color: #999; font-size: 28rpx; }
.sub-text { display: block; margin-top: 12rpx; font-size: 24rpx; color: #bbb; }
.loading { text-align: center; padding: 60rpx; color: #999; }
</style>
+18
View File
@@ -46,6 +46,22 @@
<text class="fs-brief">分享经验 · 共建题库 · 帮更多人</text> <text class="fs-brief">分享经验 · 共建题库 · 帮更多人</text>
</view> </view>
</view> </view>
<view class="feature-secondary">
<view class="fs-card card" @click="goBank">
<view class="fs-top">
<view class="fs-icon fs-progress"><text class="fs-emoji">📚</text></view>
<text class="fs-name">公司真题库</text>
</view>
<text class="fs-brief">大厂真题 · 岗位分类 · 参考思路</text>
</view>
<view class="fs-card card" @click="goInternship">
<view class="fs-top">
<view class="fs-icon fs-contribute"><text class="fs-emoji">🔍</text></view>
<text class="fs-name">实习搜索</text>
</view>
<text class="fs-brief">热门实习 · 一键搜索 · 精准匹配</text>
</view>
</view>
</view> </view>
</view> </view>
@@ -153,6 +169,8 @@ const goProfile = () => uni.switchTab({ url: '/pages/user/user' })
const goInterview = () => uni.navigateTo({ url: '/pages/interview/interview' }) const goInterview = () => uni.navigateTo({ url: '/pages/interview/interview' })
const goProgress = () => uni.navigateTo({ url: '/pages/progress/progress' }) const goProgress = () => uni.navigateTo({ url: '/pages/progress/progress' })
const goContribute = () => uni.navigateTo({ url: '/pages/contribute/contribute' }) const goContribute = () => uni.navigateTo({ url: '/pages/contribute/contribute' })
const goBank = () => uni.navigateTo({ url: '/pages/company-bank/bank' })
const goInternship = () => uni.navigateTo({ url: '/pages/internship/internship' })
const startInterview = (pos) => uni.navigateTo({ url: `/pages/interview/interview?position=${encodeURIComponent(pos.name)}` }) const startInterview = (pos) => uni.navigateTo({ url: `/pages/interview/interview?position=${encodeURIComponent(pos.name)}` })
</script> </script>
-2
View File
@@ -41,8 +41,6 @@
<!-- 验证码登录 --> <!-- 验证码登录 -->
<view v-else> <view v-else>
<!-- 调试信息发布前删掉 -->
<view class="debug-info" v-if="true">debug: emailSent={{emailSent}} cooldown={{cooldown}}</view>
<view class="field"> <view class="field">
<text class="field-label">邮箱</text> <text class="field-label">邮箱</text>
<view class="inline-row"> <view class="inline-row">
+154 -65
View File
@@ -10,17 +10,13 @@
<view class="plans"> <view class="plans">
<!-- 免费版 --> <!-- 免费版 -->
<view class="plan-card free" :class="{ active: isLoggedIn && plan === 'free' }"> <view class="plan-card free" :class="{ active: plan === 'free' && isLoggedIn }">
<view class="plan-header"> <view class="plan-header">
<text class="plan-name">免费版</text> <text class="plan-name">免费版</text>
<view class="plan-price"><text class="price-num">免费</text></view> <view class="plan-price"><text class="price-num">免费</text></view>
</view> </view>
<view class="plan-features"> <view class="plan-features">
<text class="feat"> 每日 {{ limits.interview.dailyFreeLimit || 3 }} AI 模拟面试</text> <text class="feat" v-for="f in freeFeatures" :key="f"> {{ f }}</text>
<text class="feat"> 每场最多 {{ limits.interview.maxRoundsFree || 5 }} AI 对话</text>
<text class="feat"> 基础面试报告</text>
<text class="feat"> 简历诊断</text>
<text class="feat"> 简历优化</text>
</view> </view>
<view class="plan-status" v-if="isLoggedIn && plan === 'free'">当前使用</view> <view class="plan-status" v-if="isLoggedIn && plan === 'free'">当前使用</view>
<view class="plan-status hint" v-else-if="!isLoggedIn">注册即用</view> <view class="plan-status hint" v-else-if="!isLoggedIn">注册即用</view>
@@ -31,47 +27,62 @@
<view class="plan-badge"> 推荐</view> <view class="plan-badge"> 推荐</view>
<view class="plan-header"> <view class="plan-header">
<text class="plan-name">成长版</text> <text class="plan-name">成长版</text>
<text class="plan-price"><text class="price-num">{{ priceText }}</text><text class="price-unit" v-if="plan !== 'growth' || !isLoggedIn">/</text></text> <text class="plan-price"><text class="price-num">{{ growthPriceText }}</text><text class="price-unit">/</text></text>
</view> </view>
<view class="plan-features"> <view class="plan-features">
<text class="feat"> 免费版全部权益</text> <text class="feat" v-for="f in growthFeatures" :key="f"> {{ f }}</text>
<text class="feat"> 无限面试次数</text>
<text class="feat"> 每场最多 {{ limits.interview.maxRoundsVip || 10 }} AI 对话</text>
<text class="feat"> 详细面试报告四维评分</text>
<text class="feat"> 进步轨迹雷达图 + 打卡</text>
<text class="feat"> 参考回答思路</text>
<text class="feat"> 公司真题库</text>
</view> </view>
<view class="plan-action" v-if="!isLoggedIn" @click="goLogin">登录后开通</view> <view class="plan-action" v-if="!isLoggedIn" @click="goLogin">登录后开通</view>
<view class="plan-action owned" v-else-if="plan === 'growth'"> 已开通</view> <view class="plan-action owned" v-else-if="plan !== 'free'"> 已开通</view>
<view class="plan-action" v-else @click="startPay">{{ priceText }} 立即开通</view> <view class="plan-action" v-else @click="startPay('growth')">{{ growthPriceText }} 立即开通</view>
</view>
<!-- 冲刺版 -->
<view class="plan-card sprint" :class="{ active: plan === 'sprint' && isLoggedIn }">
<view class="plan-badge sprint-badge">🚀 冲刺</view>
<view class="plan-header">
<text class="plan-name">冲刺版</text>
<text class="plan-price"><text class="price-num price-sprint">¥49.9</text><text class="price-unit">/</text></text>
</view>
<view class="plan-features">
<text class="feat" v-for="f in sprintFeatures" :key="f"> {{ f }}</text>
</view>
<view class="plan-action" v-if="!isLoggedIn" @click="goLogin">登录后开通</view>
<view class="plan-action owned" v-else-if="plan === 'sprint'"> 已开通</view>
<view class="plan-action" v-else-if="plan === 'growth'" @click="startPay('sprint')">升级至冲刺版</view>
<view class="plan-action" v-else @click="startPay('sprint')">¥49.9/ 立即开通</view>
</view> </view>
</view> </view>
<!-- 支付弹窗 --> <!-- 支付弹窗 -->
<view class="modal-overlay" v-if="showPayModal" @click="showPayModal = false"> <view class="modal-overlay" v-if="showPayModal" @click="cancelPay">
<view class="modal-content" @click.stop> <view class="modal-content" @click.stop>
<!-- 二维码支付H5 --> <template v-if="payLoading">
<template v-if="!isMp && payCodeUrl"> <text class="modal-title">正在创建支付...</text>
</template>
<template v-else-if="!isMp && payCodeUrl">
<text class="modal-title">微信扫码支付</text> <text class="modal-title">微信扫码支付</text>
<canvas canvas-id="payQrcode" class="qr-canvas"></canvas> <canvas canvas-id="payQrcode" class="qr-canvas"></canvas>
<text class="modal-hint">请用微信扫码完成支付</text> <text class="modal-hint">请用微信扫码完成支付</text>
<text class="modal-close" @click="showPayModal = false">取消支付</text> <text class="modal-hint">支付成功后将自动跳转</text>
<text class="modal-close" @click="cancelPay">取消支付</text>
</template> </template>
<!-- JSAPI 支付小程序 --> <template v-else-if="isMp && !payLoading">
<template v-if="isMp">
<text class="modal-title">微信支付</text> <text class="modal-title">微信支付</text>
<text class="modal-hint">即将调起微信支付...</text> <text class="modal-hint">即将调起微信支付...</text>
</template> </template>
<!-- 加载中 --> <template v-if="payError">
<text class="modal-title" v-if="!payCodeUrl && !isMp">正在创建支付...</text> <text class="modal-title pay-error">支付异常</text>
<text class="modal-hint">{{ payError }}</text>
<text class="modal-close" @click="cancelPay">关闭</text>
</template>
</view> </view>
</view> </view>
<!-- 成功提示 --> <!-- 支付中提示 -->
<view class="pay-success" v-if="paySuccess"> <view class="pay-success" v-if="paySuccess">
<text class="success-icon">🎉</text> <text class="success-icon">🎉</text>
<text class="success-text">开通成功成长版已生效</text> <text class="success-text">开通成功{{ payingPlanName }}已生效</text>
</view> </view>
</view> </view>
</template> </template>
@@ -88,13 +99,21 @@ const currentPlanName = ref('免费版')
const paySuccess = ref(false) const paySuccess = ref(false)
const showPayModal = ref(false) const showPayModal = ref(false)
const payCodeUrl = ref('') const payCodeUrl = ref('')
const priceText = ref('¥19.9') const payLoading = ref(false)
const limits = ref({ const payError = ref('')
interview: { dailyFreeLimit: 3, maxRoundsFree: 5, maxRoundsVip: 10 }, const payingPlanName = ref('')
diagnosis: { dailyFreeLimit: 2 }, const payingPlan = ref('')
optimize: { dailyFreeLimit: 2 }, const growthPriceText = ref('¥19.9')
price: { monthly: 1990 }, const currentOutTradeNo = ref('')
}) const freeFeatures = ['每日 2 次 AI 模拟面试', '基础面试报告', '通用题库随机出题', '简历诊断(限 3 次)']
const growthFeatures = [
'免费版全部权益', '无限面试次数', '详细面试报告(四维评分)',
'进步轨迹雷达图 + 打卡', '每日一题推送', '参考回答思路', '公司真题库',
]
const sprintFeatures = [
'成长版全部权益', 'AI 语音分析(语气词/语速检测)', '技能缺口分析报告',
'学习路径推荐', '真人导师 1v1 点评(每月 1 次)', '简历精修(每月 1 次)', '内推优先',
]
const token = () => uni.getStorageSync('token') || '' const token = () => uni.getStorageSync('token') || ''
@@ -117,58 +136,88 @@ onMounted(async () => {
plan.value = d.plan || 'free' plan.value = d.plan || 'free'
currentPlanName.value = d.planName || '免费版' currentPlanName.value = d.planName || '免费版'
} }
if (lres.statusCode === 200 && lres.data) { if (lres.statusCode === 200 && lres.data?.price) {
const d = lres.data const p = lres.data.price
if (d.interview) limits.value.interview = d.interview growthPriceText.value = `¥${(p.monthly / 100).toFixed(1)}`
if (d.diagnosis) limits.value.diagnosis = d.diagnosis
if (d.optimize) limits.value.optimize = d.optimize
if (d.price) {
limits.value.price = d.price
priceText.value = `¥${(d.price.monthly / 100).toFixed(1)}`
}
} }
} catch (e) { /* ignore */ } } catch (e) { /* ignore */ }
}) })
const goLogin = () => uni.navigateTo({ url: '/pages/login/login' }) const goLogin = () => uni.navigateTo({ url: '/pages/login/login' })
const cancelPay = () => {
showPayModal.value = false
payCodeUrl.value = ''
payLoading.value = false
payError.value = ''
}
/** 创建支付订单 */ /** 创建支付订单 */
const startPay = async () => { const startPay = async (selectedPlan) => {
const t = token() const t = token()
if (!t) { uni.showToast({ title: '请先登录', icon: 'none' }); return } if (!t) { uni.showToast({ title: '请先登录', icon: 'none' }); return }
showPayModal.value = true
try { payingPlan.value = selectedPlan
// #ifdef MP-WEIXIN
payingPlanName.value = selectedPlan === 'sprint' ? '冲刺版' : '成长版'
// #endif
// #ifndef MP-WEIXIN
payingPlanName.value = selectedPlan === 'sprint' ? '冲刺版' : '成长版'
// #endif
showPayModal.value = true
payLoading.value = true
payError.value = ''
const planLabel = selectedPlan || 'growth'
if (isMp.value) { if (isMp.value) {
// JSAPI // JSAPI
try {
const res = await uni.request({ const res = await uni.request({
url: api('/payment/jsapi'), method: 'POST', url: api('/payment/jsapi'), method: 'POST',
data: { plan: planLabel },
header: { 'Authorization': `Bearer ${t}`, 'Content-Type': 'application/json' }, header: { 'Authorization': `Bearer ${t}`, 'Content-Type': 'application/json' },
}) })
payLoading.value = false
if (res.statusCode === 200 && res.data?.payParams) { if (res.statusCode === 200 && res.data?.payParams) {
const pp = res.data.payParams const pp = res.data.payParams
currentOutTradeNo.value = res.data.outTradeNo || ''
//
uni.requestPayment({ uni.requestPayment({
provider: 'wxpay', provider: 'wxpay',
timeStamp: pp.timeStamp, timeStamp: pp.timeStamp,
nonceStr: pp.nonceStr, nonceStr: pp.nonceStr,
package: pp.package, package: pp.package,
signType: pp.signType, signType: pp.signType || 'RSA',
paySign: pp.paySign, paySign: pp.paySign,
success: () => checkPayResult(), success: () => pollPayResult(res.data.prepayId, planLabel),
fail: () => { showPayModal.value = false; uni.showToast({ title: '支付取消', icon: 'none' }) }, fail: (err) => { payError.value = '支付取消或失败'; uni.showToast({ title: '支付取消', icon: 'none' }) },
}) })
} else { } else {
showPayModal.value = false payLoading.value = false
payError.value = res.data?.message || '创建订单失败'
uni.showToast({ title: '创建订单失败', icon: 'none' }) uni.showToast({ title: '创建订单失败', icon: 'none' })
} }
} catch (e) {
payLoading.value = false
payError.value = '网络错误,请重试'
uni.showToast({ title: '网络错误', icon: 'none' })
}
} else { } else {
// H5Native // H5
try {
const res = await uni.request({ const res = await uni.request({
url: api('/payment/create'), method: 'POST', url: api('/payment/create'), method: 'POST',
data: { plan: planLabel },
header: { 'Authorization': `Bearer ${t}`, 'Content-Type': 'application/json' }, header: { 'Authorization': `Bearer ${t}`, 'Content-Type': 'application/json' },
}) })
payLoading.value = false
if (res.statusCode === 200 && res.data?.codeUrl) { if (res.statusCode === 200 && res.data?.codeUrl) {
payCodeUrl.value = res.data.codeUrl payCodeUrl.value = res.data.codeUrl
currentOutTradeNo.value = res.data.outTradeNo
nextTick(() => { nextTick(() => {
try { try {
const ctx = uni.createCanvasContext('payQrcode') const ctx = uni.createCanvasContext('payQrcode')
@@ -182,35 +231,71 @@ const startPay = async () => {
uqrcode.drawCanvas(ctx) uqrcode.drawCanvas(ctx)
} catch(e) { console.error('二维码生成失败', e) } } catch(e) { console.error('二维码生成失败', e) }
}) })
//
pollPayResult(res.data.outTradeNo, planLabel)
} else { } else {
showPayModal.value = false payError.value = res.data?.message || '支付服务暂不可用'
uni.showToast({ title: '支付服务暂不可用', icon: 'none' }) uni.showToast({ title: '支付服务暂不可用', icon: 'none' })
} }
}
} catch (e) { } catch (e) {
showPayModal.value = false payLoading.value = false
uni.showToast({ title: '支付服务暂不可用', icon: 'none' }) payError.value = '网络错误,请重试'
uni.showToast({ title: '网络错误', icon: 'none' })
}
} }
} }
/** 支付成功后查询并更新状态 */ /** 轮询订单状态 */
const checkPayResult = async () => { const pollPayResult = async (outTradeNo, selectedPlan) => {
uni.showLoading({ title: '查询支付结果...' }) if (!outTradeNo) return
const maxAttempts = 30
let attempts = 0
const poll = async () => {
attempts++
try { try {
await new Promise(r => setTimeout(r, 2000)) const res = await uni.request({
const res = await uni.request({ url: api('/member/pay'), method: 'POST', header: { 'Authorization': `Bearer ${token()}`, 'Content-Type': 'application/json' } }) url: api(`/payment/check/${outTradeNo}`), method: 'GET',
header: { 'Authorization': `Bearer ${token()}` },
})
if (res.statusCode === 200 && res.data?.status === 'success') {
//
await activatePlan(outTradeNo, selectedPlan)
return
}
} catch (e) { /* ignore */ }
if (attempts < maxAttempts) {
setTimeout(poll, 2000)
} else {
payError.value = '支付结果查询超时,请联系客服'
uni.showToast({ title: '支付查询超时', icon: 'none' })
}
}
setTimeout(poll, 2000)
}
/** 激活套餐 */
const activatePlan = async (outTradeNo, selectedPlan) => {
try {
const res = await uni.request({
url: api('/payment/activate'), method: 'POST',
data: { outTradeNo },
header: { 'Authorization': `Bearer ${token()}`, 'Content-Type': 'application/json' },
})
if (res.statusCode === 200 && res.data?.success) { if (res.statusCode === 200 && res.data?.success) {
paySuccess.value = true paySuccess.value = true
showPayModal.value = false showPayModal.value = false
plan.value = 'growth' plan.value = selectedPlan === 'sprint' ? 'sprint' : 'growth'
currentPlanName.value = '成长版' currentPlanName.value = selectedPlan === 'sprint' ? '冲刺版' : '成长版'
uni.hideLoading()
uni.showToast({ title: '🎉 开通成功!', icon: 'success' }) uni.showToast({ title: '🎉 开通成功!', icon: 'success' })
} else { } else {
uni.hideLoading() uni.showToast({ title: res.data?.message || '激活失败', icon: 'none' })
uni.showToast({ title: '支付未完成,请稍后重试', icon: 'none' }) }
} catch (e) {
payError.value = '激活失败,请联系客服'
uni.showToast({ title: '激活失败', icon: 'none' })
} }
} catch { uni.hideLoading(); uni.showToast({ title: '查询失败', icon: 'none' }) }
} }
</script> </script>
@@ -222,12 +307,15 @@ const checkPayResult = async () => {
.plans { padding: 0 32rpx; margin-top: -40rpx; display: flex; flex-direction: column; gap: 24rpx; } .plans { padding: 0 32rpx; margin-top: -40rpx; display: flex; flex-direction: column; gap: 24rpx; }
.plan-card { background: #FFFFFF; border-radius: var(--radius-xl); padding: 32rpx; box-shadow: var(--shadow-sm); position: relative; } .plan-card { background: #FFFFFF; border-radius: var(--radius-xl); padding: 32rpx; box-shadow: var(--shadow-sm); position: relative; }
.plan-card.growth { border: 2rpx solid var(--color-primary); } .plan-card.growth { border: 2rpx solid var(--color-primary); }
.plan-card.sprint { border: 2rpx solid #F59E0B; }
.plan-card.active { border-color: var(--color-primary); } .plan-card.active { border-color: var(--color-primary); }
.plan-badge { position: absolute; top: -12rpx; right: 24rpx; background: linear-gradient(135deg, var(--color-gradient-start), var(--color-gradient-mid)); color: #FFF; font-size: 20rpx; padding: 4rpx 20rpx; border-radius: var(--radius-round); font-weight: 600; } .plan-badge { position: absolute; top: -12rpx; right: 24rpx; background: linear-gradient(135deg, var(--color-gradient-start), var(--color-gradient-mid)); color: #FFF; font-size: 20rpx; padding: 4rpx 20rpx; border-radius: var(--radius-round); font-weight: 600; }
.sprint-badge { background: linear-gradient(135deg, #F59E0B, #F97316); }
.plan-header { display: flex; justify-content: space-between; align-items: baseline; margin-bottom: 20rpx; } .plan-header { display: flex; justify-content: space-between; align-items: baseline; margin-bottom: 20rpx; }
.plan-name { font-size: 30rpx; font-weight: 700; color: var(--color-text); } .plan-name { font-size: 30rpx; font-weight: 700; color: var(--color-text); }
.price-num { font-size: 44rpx; font-weight: 800; color: var(--color-primary); } .price-num { font-size: 44rpx; font-weight: 800; color: var(--color-primary); }
.price-unit { font-size: 22rpx; color: var(--color-text-tertiary); } .price-unit { font-size: 22rpx; color: var(--color-text-tertiary); }
.price-sprint { color: #D97706; }
.plan-features { display: flex; flex-direction: column; gap: 10rpx; margin-bottom: 24rpx; } .plan-features { display: flex; flex-direction: column; gap: 10rpx; margin-bottom: 24rpx; }
.feat { font-size: 24rpx; color: var(--color-text-secondary); } .feat { font-size: 24rpx; color: var(--color-text-secondary); }
.plan-status { text-align: center; background: #F3F4F6; padding: 14rpx; border-radius: var(--radius-sm); font-size: 24rpx; color: var(--color-text-secondary); } .plan-status { text-align: center; background: #F3F4F6; padding: 14rpx; border-radius: var(--radius-sm); font-size: 24rpx; color: var(--color-text-secondary); }
@@ -242,6 +330,7 @@ const checkPayResult = async () => {
.modal-overlay { position: fixed; top: 0; left: 0; right: 0; bottom: 0; background: rgba(0,0,0,0.5); display: flex; align-items: center; justify-content: center; z-index: 100; } .modal-overlay { position: fixed; top: 0; left: 0; right: 0; bottom: 0; background: rgba(0,0,0,0.5); display: flex; align-items: center; justify-content: center; z-index: 100; }
.modal-content { background: #FFF; border-radius: var(--radius-xl); padding: 40rpx; width: 70%; display: flex; flex-direction: column; align-items: center; gap: 20rpx; } .modal-content { background: #FFF; border-radius: var(--radius-xl); padding: 40rpx; width: 70%; display: flex; flex-direction: column; align-items: center; gap: 20rpx; }
.modal-title { font-size: 28rpx; font-weight: 600; color: var(--color-text); } .modal-title { font-size: 28rpx; font-weight: 600; color: var(--color-text); }
.pay-error { color: var(--color-error); }
.qr-canvas { width: 400rpx; height: 400rpx; background: #FFF; border-radius: var(--radius-md); } .qr-canvas { width: 400rpx; height: 400rpx; background: #FFF; border-radius: var(--radius-md); }
.modal-hint { font-size: 22rpx; color: var(--color-text-tertiary); } .modal-hint { font-size: 22rpx; color: var(--color-text-tertiary); }
.modal-close { font-size: 22rpx; color: var(--color-text-tertiary); padding: 8rpx; } .modal-close { font-size: 22rpx; color: var(--color-text-tertiary); padding: 8rpx; }
+61 -1
View File
@@ -48,6 +48,38 @@
</view> </view>
</view> </view>
<!-- 技能缺口分析 -->
<view class="section" v-if="skillsGap">
<view class="section-header">
<text class="section-title">技能缺口</text>
<text class="section-desc">{{ skillsGap.assessment }}</text>
</view>
<view class="gap-card card">
<view class="gap-item" v-for="g in skillsGap.gaps" :key="g.dimension">
<view class="gap-header">
<text class="gap-name">{{ g.dimension }}</text>
<text class="gap-badge" :class="g.level === '严重不足' ? 'badge-danger' : g.level === '需提升' ? 'badge-warn' : 'badge-ok'">{{ g.level }}</text>
</view>
<view class="gap-bar-bg">
<view class="gap-bar-fill" :style="{ width: (g.currentScore / g.targetScore * 100) + '%' }"></view>
<view class="gap-target" :style="{ left: '100%' }"></view>
</view>
<view class="gap-info">
<text class="gap-score">当前 {{ g.currentScore }}</text>
<text class="gap-target-text">目标 {{ g.targetScore }}</text>
<text class="gap-diff" v-if="g.gap > 0"> {{ g.gap }}</text>
</view>
</view>
<view class="gap-suggestions" v-if="skillsGap.suggestions.length">
<text class="gap-suggest-title">提升建议</text>
<view class="suggest-item" v-for="s in skillsGap.suggestions" :key="s.dimension">
<text class="suggest-dim">{{ s.dimension }}</text>
<text class="suggest-tip">{{ s.tip }}</text>
</view>
</view>
</view>
</view>
<!-- 打卡日历 --> <!-- 打卡日历 -->
<view class="section"> <view class="section">
<view class="section-header"> <view class="section-header">
@@ -106,6 +138,7 @@ import { api } from '../../config'
const stats = ref({ completedInterviews: 0, avgScore: 0, streak: 0 }) const stats = ref({ completedInterviews: 0, avgScore: 0, streak: 0 })
const progress = ref({ dimensions: {}, interviews: [], recentScores: [] }) const progress = ref({ dimensions: {}, interviews: [], recentScores: [] })
const skillsGap = ref(null)
const dimensions = ref([ const dimensions = ref([
{ key: 'logic', label: '逻辑思维', value: 0, color: 'linear-gradient(90deg, #6366F1, #818CF8)' }, { key: 'logic', label: '逻辑思维', value: 0, color: 'linear-gradient(90deg, #6366F1, #818CF8)' },
{ key: 'expression', label: '表达能力', value: 0, color: 'linear-gradient(90deg, #10B981, #34D399)' }, { key: 'expression', label: '表达能力', value: 0, color: 'linear-gradient(90deg, #10B981, #34D399)' },
@@ -138,6 +171,15 @@ onMounted(async () => {
} }
} catch (e) { console.error(e) } } catch (e) { console.error(e) }
try {
const gapRes = await uni.request({
url: api('/analyze/skills-gap'), method: 'POST',
header: { 'Authorization': `Bearer ${t}`, 'Content-Type': 'application/json' },
data: {},
})
if (gapRes.statusCode === 200) skillsGap.value = gapRes.data
} catch (e) { /* skills gap not available */ }
try { try {
// Load stats // Load stats
const sres = await uni.request({ const sres = await uni.request({
@@ -173,7 +215,7 @@ const formatDate = (d) => {
return `${date.getMonth() + 1}/${date.getDate()} ${date.getHours()}:${String(date.getMinutes()).padStart(2, '0')}` return `${date.getMonth() + 1}/${date.getDate()} ${date.getHours()}:${String(date.getMinutes()).padStart(2, '0')}`
} }
const scoreClass = (s) => s >= 80 ? 'score-high' : s >= 60 ? 'score-mid' : 'score-low' const scoreClass = (s) => s >= 80 ? 'score-high' : s >= 60 ? 'score-mid' : 'score-low'
const viewReport = (id) => uni.navigateTo({ url: `/pages/report/report?id=${id}` }) const viewReport = (id) => uni.navigateTo({ url: `/pages/report/report?interviewId=${id}` })
</script> </script>
<style scoped> <style scoped>
@@ -208,6 +250,24 @@ const viewReport = (id) => uni.navigateTo({ url: `/pages/report/report?id=${id}`
.dim-bar-bg { height: 16rpx; background: #F3F4F6; border-radius: 8rpx; overflow: hidden; } .dim-bar-bg { height: 16rpx; background: #F3F4F6; border-radius: 8rpx; overflow: hidden; }
.dim-bar-fill { height: 100%; border-radius: 8rpx; transition: width 0.8s cubic-bezier(0.4, 0, 0.2, 1); } .dim-bar-fill { height: 100%; border-radius: 8rpx; transition: width 0.8s cubic-bezier(0.4, 0, 0.2, 1); }
.gap-card { padding: 32rpx; border-radius: var(--radius-xl); }
.gap-item { margin-bottom: 24rpx; }
.gap-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 8rpx; }
.gap-name { font-size: 24rpx; font-weight: 600; color: var(--color-text); }
.gap-badge { font-size: 20rpx; font-weight: 600; padding: 2rpx 12rpx; border-radius: 20rpx; }
.badge-danger { background: #FEE2E2; color: #DC2626; }
.badge-warn { background: #FEF3C7; color: #D97706; }
.badge-ok { background: #D1FAE5; color: #059669; }
.gap-bar-bg { height: 20rpx; background: #F3F4F6; border-radius: 10rpx; position: relative; overflow: visible; }
.gap-bar-fill { height: 100%; border-radius: 10rpx; background: linear-gradient(90deg, #EF4444, #F59E0B, #10B981); transition: width 0.8s ease; }
.gap-target { position: absolute; top: -4rpx; font-size: 28rpx; color: #6366F1; }
.gap-info { display: flex; gap: 16rpx; margin-top: 6rpx; font-size: 20rpx; color: var(--color-text-tertiary); }
.gap-diff { color: #EF4444; font-weight: 600; }
.gap-suggestions { margin-top: 24rpx; padding-top: 20rpx; border-top: 1rpx solid var(--color-border); }
.gap-suggest-title { font-size: 24rpx; font-weight: 700; color: var(--color-text); margin-bottom: 12rpx; display: block; }
.suggest-item { font-size: 22rpx; color: var(--color-text-secondary); line-height: 1.6; margin-bottom: 8rpx; }
.suggest-dim { font-weight: 600; color: var(--color-primary); }
.streak-card { padding: 24rpx; border-radius: var(--radius-xl); } .streak-card { padding: 24rpx; border-radius: var(--radius-xl); }
.streak-grid { display: flex; justify-content: space-around; } .streak-grid { display: flex; justify-content: space-around; }
.streak-day { display: flex; flex-direction: column; align-items: center; gap: 6rpx; } .streak-day { display: flex; flex-direction: column; align-items: center; gap: 6rpx; }
+1 -1
View File
@@ -159,7 +159,7 @@ async function saveResult() {
targetPosition: position.value, targetPosition: position.value,
type: isOptimize.value ? 'optimize' : 'diagnosis', type: isOptimize.value ? 'optimize' : 'diagnosis',
}; };
const res = await api.resume.create(payload); const res = await api.resume.create(payload.title, payload.originalContent, payload.targetPosition);
resultId.value = res._id || res.id; resultId.value = res._id || res.id;
saved.value = true; saved.value = true;
uni.showToast({ title: '保存成功', icon: 'success' }); uni.showToast({ title: '保存成功', icon: 'success' });
+7 -3
View File
@@ -147,7 +147,7 @@
</template> </template>
<script setup> <script setup>
import { ref, onMounted, computed } from 'vue' import { ref, onMounted } from 'vue'
import { onLoad } from '@dcloudio/uni-app' import { onLoad } from '@dcloudio/uni-app'
import { api } from '../../config' import { api } from '../../config'
@@ -206,9 +206,11 @@ const selectResume = (r) => {
const submitDiagnose = async () => { const submitDiagnose = async () => {
const content = getContent() const content = getContent()
if (!content) { uni.showToast({ title: '请先输入简历内容', icon: 'none' }); return } if (!content) { uni.showToast({ title: '请先输入简历内容', icon: 'none' }); return }
const token = uni.getStorageSync('token')
if (!token) { uni.showToast({ title: '请先登录', icon: 'none' }); return }
analyzing.value = true; result.value = null analyzing.value = true; result.value = null
try { try {
const res = await uni.request({ url: api('/analyze/diagnosis'), method: 'POST', header: { 'Content-Type': 'application/json' }, data: { content } }) const res = await uni.request({ url: api('/analyze/diagnosis'), method: 'POST', header: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${token}` }, data: { content } })
if (res.statusCode === 200) { result.value = res.data; resultType.value = 'diagnosis' } if (res.statusCode === 200) { result.value = res.data; resultType.value = 'diagnosis' }
} catch { uni.showToast({ title: '诊断失败', icon: 'none' }) } } catch { uni.showToast({ title: '诊断失败', icon: 'none' }) }
finally { analyzing.value = false } finally { analyzing.value = false }
@@ -217,10 +219,12 @@ const submitDiagnose = async () => {
const submitOptimize = async () => { const submitOptimize = async () => {
const content = getContent() const content = getContent()
if (!content) { uni.showToast({ title: '请先输入简历内容', icon: 'none' }); return } if (!content) { uni.showToast({ title: '请先输入简历内容', icon: 'none' }); return }
const token = uni.getStorageSync('token')
if (!token) { uni.showToast({ title: '请先登录', icon: 'none' }); return }
analyzing.value = true; result.value = null analyzing.value = true; result.value = null
try { try {
const direction = targetPosition.value || '通用岗位' const direction = targetPosition.value || '通用岗位'
const res = await uni.request({ url: api('/analyze/optimize'), method: 'POST', header: { 'Content-Type': 'application/json' }, data: { content, direction } }) const res = await uni.request({ url: api('/analyze/optimize'), method: 'POST', header: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${token}` }, data: { content, direction } })
if (res.statusCode === 200) { result.value = res.data; resultType.value = 'optimize' } if (res.statusCode === 200) { result.value = res.data; resultType.value = 'optimize' }
} catch { uni.showToast({ title: '优化失败', icon: 'none' }) } } catch { uni.showToast({ title: '优化失败', icon: 'none' }) }
finally { analyzing.value = false } finally { analyzing.value = false }
+36 -2
View File
@@ -40,8 +40,10 @@ export const apiService = {
stats: () => request(API_ENDPOINTS.INTERVIEW.STATS, 'GET', undefined, true), stats: () => request(API_ENDPOINTS.INTERVIEW.STATS, 'GET', undefined, true),
}, },
analyze: { analyze: {
diagnosis: (content: string) => request(API_ENDPOINTS.ANALYZE.DIAGNOSIS, 'POST', { content }), diagnosis: (content: string) => request(API_ENDPOINTS.ANALYZE.DIAGNOSIS, 'POST', { content }, true),
optimize: (content: string, direction: string) => request(API_ENDPOINTS.ANALYZE.OPTIMIZE, 'POST', { content, direction }), optimize: (content: string, direction: string) => request(API_ENDPOINTS.ANALYZE.OPTIMIZE, 'POST', { content, direction }, true),
skillsGap: (targetPosition?: string) =>
request(API_ENDPOINTS.ANALYZE.SKILLS_GAP, 'POST', { targetPosition }, true),
}, },
resume: { resume: {
create: (title: string, content: string, targetPosition?: string) => create: (title: string, content: string, targetPosition?: string) =>
@@ -49,6 +51,38 @@ export const apiService = {
list: () => request(API_ENDPOINTS.RESUME.LIST, 'GET', undefined, true), list: () => request(API_ENDPOINTS.RESUME.LIST, 'GET', undefined, true),
delete: (id: string) => request(API_ENDPOINTS.RESUME.DELETE(id), 'DELETE', undefined, true), delete: (id: string) => request(API_ENDPOINTS.RESUME.DELETE(id), 'DELETE', undefined, true),
}, },
progress: {
get: () => request(API_ENDPOINTS.PROGRESS.GET, 'GET', undefined, true),
stats: () => request(API_ENDPOINTS.PROGRESS.STATS, 'GET', undefined, true),
},
contribution: {
create: (data: any) => request(API_ENDPOINTS.CONTRIBUTION.CREATE, 'POST', data, true),
my: () => request(API_ENDPOINTS.CONTRIBUTION.MY, 'GET', undefined, true),
bank: (company: string, position: string) =>
request(API_ENDPOINTS.CONTRIBUTION.BANK(company, position), 'GET', undefined, true),
company: (company: string) =>
request(API_ENDPOINTS.CONTRIBUTION.COMPANY(company), 'GET', undefined, true),
},
member: {
plans: () => request(API_ENDPOINTS.MEMBER.PLANS, 'GET', undefined),
status: () => request(API_ENDPOINTS.MEMBER.STATUS, 'GET', undefined, true),
pay: (outTradeNo: string) => request(API_ENDPOINTS.MEMBER.PAY, 'POST', { outTradeNo }, true),
sprintDeduct: () => request(API_ENDPOINTS.MEMBER.SPRINT_DEDUCT, 'POST', undefined, true),
},
payment: {
create: (plan: string) => request(API_ENDPOINTS.PAYMENT.CREATE, 'POST', { plan }, true),
jsapi: (plan: string) => request(API_ENDPOINTS.PAYMENT.JSAPI, 'POST', { plan }, true),
check: (outTradeNo: string) => request(API_ENDPOINTS.PAYMENT.CHECK(outTradeNo), 'GET', undefined, true),
activate: (outTradeNo: string) => request(API_ENDPOINTS.PAYMENT.ACTIVATE, 'POST', { outTradeNo }, true),
},
dailyQuestion: {
today: (position?: string) => {
const query = position ? `?position=${encodeURIComponent(position)}` : ''
return request(API_ENDPOINTS.DAILY_QUESTION.TODAY + query, 'GET', undefined, true)
},
byPosition: (position: string) =>
request(API_ENDPOINTS.DAILY_QUESTION.BY_POSITION(position), 'GET', undefined, true),
},
} }
export default apiService export default apiService