feat: 品牌更新 + 每日一题公开随机 + 面试计时器修复 + 反馈功能 + 营销文案
- 品牌名统一:职引 → 宇之然AI磁场,SEO 双名策略保留搜索关键词 - 每日一题:放开未登录访问,后端改用 \ 随机取题,灌入 23 题覆盖 8 个岗位 - 面试计时器:timerSeconds 改为 ref 修复 UI 不更新,完成自动停止,增加轮次提示 - 反馈功能:FeedbackModule 前后端完整实现 + admin 面板 + Playwright 测试 - 管理后台:所有列表分页改为 limit=10 + 加载更多按钮 - 录音 UX:修复异步竞态 + 点击开始/停止 + 空语音友好提示 + 发送按钮录音中禁用 - 营销文案:三端平台(公众号/知乎/小红书)推广方案 + 可复制 HTML 文件
This commit is contained in:
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "zhiyin-backend",
|
||||
"version": "1.0.0",
|
||||
"description": "职引 - AI简历优化后端服务",
|
||||
"description": "宇之然AI磁场 - AI简历优化后端服务",
|
||||
"main": "dist/src/main.js",
|
||||
"scripts": {
|
||||
"start": "nest start",
|
||||
|
||||
@@ -10,6 +10,7 @@ import { Resume, ResumeDocument } from '../resume/resume.schema'
|
||||
import { PaymentOrder, PaymentOrderDocument } from '../payment/payment-order.schema'
|
||||
import { SiteConfig, SiteConfigDocument } from '../schemas/site-config.schema'
|
||||
import { ShareRecord, ShareRecordDocument, ShareVisit, ShareVisitDocument } from '../share/share.schema'
|
||||
import { Feedback, FeedbackDocument } from '../feedback/feedback.schema'
|
||||
import { QuotaService } from '../user/quota.service'
|
||||
import { PricingService } from '../schemas/pricing.service'
|
||||
import { WechatPayService } from '../payment/wechat-pay.service'
|
||||
@@ -27,6 +28,7 @@ export class AdminController {
|
||||
@InjectModel(ShareRecord.name) private shareModel: Model<ShareRecordDocument>,
|
||||
@InjectModel(ShareVisit.name) private shareVisitModel: Model<ShareVisitDocument>,
|
||||
@InjectModel(Resume.name) private resumeModel: Model<ResumeDocument>,
|
||||
@InjectModel(Feedback.name) private feedbackModel: Model<FeedbackDocument>,
|
||||
private quotaService: QuotaService,
|
||||
private pricingService: PricingService,
|
||||
private wechatPay: WechatPayService,
|
||||
@@ -223,6 +225,16 @@ export class AdminController {
|
||||
return { list, total, page: +page }
|
||||
}
|
||||
|
||||
@Get('feedback')
|
||||
async getFeedback(@Query('page') page = '1', @Query('limit') limit = '10') {
|
||||
const skip = (Math.max(1, +page) - 1) * +limit
|
||||
const [list, total] = await Promise.all([
|
||||
this.feedbackModel.find().sort({ createdAt: -1 }).skip(skip).limit(+limit).lean().exec(),
|
||||
this.feedbackModel.countDocuments().exec(),
|
||||
])
|
||||
return { list, total, page: +page }
|
||||
}
|
||||
|
||||
@Get('resumes')
|
||||
async getResumes(@Query('page') page = '1', @Query('limit') limit = '20') {
|
||||
const skip = (Math.max(1, +page) - 1) * +limit
|
||||
|
||||
@@ -10,6 +10,7 @@ import { AdminGuard } from '../../common/guards/admin.guard'
|
||||
import { SiteConfig, SiteConfigSchema } from '../schemas/site-config.schema'
|
||||
import { ShareRecord, ShareRecordSchema, ShareVisit, ShareVisitSchema } from '../share/share.schema'
|
||||
import { Resume, ResumeSchema } from '../resume/resume.schema'
|
||||
import { Feedback, FeedbackSchema } from '../feedback/feedback.schema'
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
@@ -21,6 +22,7 @@ import { Resume, ResumeSchema } from '../resume/resume.schema'
|
||||
{ name: ShareRecord.name, schema: ShareRecordSchema },
|
||||
{ name: ShareVisit.name, schema: ShareVisitSchema },
|
||||
{ name: Resume.name, schema: ResumeSchema },
|
||||
{ name: Feedback.name, schema: FeedbackSchema },
|
||||
]),
|
||||
UserModule,
|
||||
],
|
||||
|
||||
@@ -1,40 +1,37 @@
|
||||
import { Controller, Get, Param, Query, UseGuards } from '@nestjs/common'
|
||||
import { Controller, Get, Param, Query } from '@nestjs/common'
|
||||
import { InjectModel } from '@nestjs/mongoose'
|
||||
import { Model } from 'mongoose'
|
||||
import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard'
|
||||
import { Public } from '../../common/decorators/public.decorator'
|
||||
import { DailyQuestion, DailyQuestionDocument } from '../schemas/daily-question.schema'
|
||||
|
||||
@Controller('daily-question')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
export class DailyQuestionController {
|
||||
constructor(
|
||||
@InjectModel(DailyQuestion.name) private dailyQuestionModel: Model<DailyQuestionDocument>,
|
||||
) {}
|
||||
|
||||
@Get()
|
||||
async getToday(@Query('position') position?: string) {
|
||||
const filter: any = {}
|
||||
if (position) filter.position = position
|
||||
@Public()
|
||||
async getRandom(@Query('position') position?: string) {
|
||||
const pipeline: any[] = []
|
||||
if (position) pipeline.push({ $match: { position } })
|
||||
pipeline.push({ $sample: { size: 1 } })
|
||||
|
||||
const question = await this.dailyQuestionModel
|
||||
.findOne(filter)
|
||||
.sort({ date: -1 })
|
||||
.exec()
|
||||
const results = await this.dailyQuestionModel.aggregate(pipeline).exec()
|
||||
if (results.length > 0) return results[0]
|
||||
|
||||
if (!question) {
|
||||
// Return a default question if no specific one found
|
||||
const defaultQ = await this.dailyQuestionModel.findOne().sort({ date: -1 }).exec()
|
||||
if (defaultQ) return defaultQ
|
||||
|
||||
return {
|
||||
position: '通用',
|
||||
question: '请做一个简单的自我介绍,突出你的核心优势和职业目标。',
|
||||
referenceAnswer: '建议结构:1) 基本信息 2) 教育背景与专业 3) 实习/项目经历中的亮点 4) 为什么选择这个岗位 5) 职业目标。控制在1-2分钟内。',
|
||||
category: 'behavioral',
|
||||
}
|
||||
// Fallback: try without position filter
|
||||
if (position) {
|
||||
const fallback = await this.dailyQuestionModel.aggregate([{ $sample: { size: 1 } }]).exec()
|
||||
if (fallback.length > 0) return fallback[0]
|
||||
}
|
||||
|
||||
return question
|
||||
return {
|
||||
position: '通用',
|
||||
question: '请做一个简单的自我介绍,突出你的核心优势和职业目标。',
|
||||
referenceAnswer: '建议结构:1) 基本信息 2) 教育背景与专业 3) 实习/项目经历中的亮点 4) 为什么选择这个岗位 5) 职业目标。控制在1-2分钟内。',
|
||||
category: 'behavioral',
|
||||
}
|
||||
}
|
||||
|
||||
@Get('position/:position')
|
||||
|
||||
@@ -37,7 +37,7 @@ export class PaymentController {
|
||||
const pricing = await this.pricingService.getConfig()
|
||||
const planCfg = pricing.plans[plan === 'sprint' ? 'sprint' : 'growth']
|
||||
const amount = planCfg.price
|
||||
const title = plan === 'sprint' ? '职引冲刺版月度会员' : '职引成长版月度会员'
|
||||
const title = plan === 'sprint' ? '宇之然AI磁场冲刺版月度会员' : '宇之然AI磁场成长版月度会员'
|
||||
const outTradeNo = `${plan === 'sprint' ? 'SPR' : 'VIP'}${Date.now()}${userId.slice(-6)}`
|
||||
const result = await this.wechatPay.nativePay(title, outTradeNo, amount)
|
||||
|
||||
@@ -106,7 +106,7 @@ export class PaymentController {
|
||||
this.logger.log(`[jsapi] pricing获取成功`)
|
||||
const planCfg = pricing.plans[plan === 'sprint' ? 'sprint' : 'growth']
|
||||
const amount = planCfg.price
|
||||
const title = plan === 'sprint' ? '职引冲刺版月度会员' : '职引成长版月度会员'
|
||||
const title = plan === 'sprint' ? '宇之然AI磁场冲刺版月度会员' : '宇之然AI磁场成长版月度会员'
|
||||
const outTradeNo = `${plan === 'sprint' ? 'SPR' : 'VIP'}${Date.now()}${userId.slice(-6)}`
|
||||
this.logger.log(`[jsapi] 准备调用微信: outTradeNo=${outTradeNo}, amount=${amount}, openid=${openid}`)
|
||||
let result: any
|
||||
|
||||
@@ -74,7 +74,7 @@ export class ResumePdfService {
|
||||
<h1>${this.escapeHtml(params.title)}</h1>
|
||||
<div class="subtitle">${params.targetPosition ? `目标岗位: ${this.escapeHtml(params.targetPosition)}` : ''}</div>
|
||||
<div class="content">${contentHtml}</div>
|
||||
<div class="footer">由 AI磁场·职引 生成</div>
|
||||
<div class="footer">由 AI磁场·宇之然AI磁场 生成</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>`
|
||||
|
||||
@@ -3,7 +3,7 @@ import { Document } from 'mongoose'
|
||||
|
||||
export type DailyQuestionDocument = DailyQuestion & Document
|
||||
|
||||
@Schema({ timestamps: true })
|
||||
@Schema({ timestamps: true, collection: 'dailyquestions' })
|
||||
export class DailyQuestion {
|
||||
@Prop({ required: true })
|
||||
position: string // 适用岗位
|
||||
|
||||
@@ -27,7 +27,7 @@ export class ShareService {
|
||||
shareCode,
|
||||
type: body.type || 'app',
|
||||
refId: body.refId || '',
|
||||
title: body.title || '我在职引发现了好东西',
|
||||
title: body.title || '我在宇之然AI磁场发现了好东西',
|
||||
description: body.description || '快来一起体验吧',
|
||||
})
|
||||
return {
|
||||
|
||||
Reference in New Issue
Block a user