Compare commits
11 Commits
e049be280e
...
v1.0.13
| Author | SHA1 | Date | |
|---|---|---|---|
| 1be5b34906 | |||
| c58bb27575 | |||
| e0de29fdd0 | |||
| 6a3cc8544e | |||
| c161ffbc3c | |||
| 7e1bf669ab | |||
| bed9dce943 | |||
| 4ac42f6575 | |||
| f72312ea52 | |||
| 7cf4636b8c | |||
| 27e4d06da7 |
@@ -189,7 +189,7 @@ cd zhiyin-app && npm run build:mp-weixin && node scripts/upload-mp.js
|
|||||||
|------|------|---------|
|
|------|------|---------|
|
||||||
| Phase 0: 战略升级 | ✅ 完成 | 定价重构(免费 + ¥19.9/月),三层壁垒设计 |
|
| Phase 0: 战略升级 | ✅ 完成 | 定价重构(免费 + ¥19.9/月),三层壁垒设计 |
|
||||||
| Phase 0.5: 壁垒构建 | ✅ 完成 | 数据飞轮(面经贡献+题库),留存入围(进步轨迹+打卡日历+每日一题) |
|
| Phase 0.5: 壁垒构建 | ✅ 完成 | 数据飞轮(面经贡献+题库),留存入围(进步轨迹+打卡日历+每日一题) |
|
||||||
| Phase 1: MVP 上线 | 🚧 当前 | 小程序审核提交、微信登录联调、生产部署、100 人内测 |
|
| Phase 1: MVP 上线 | 🚧 当前 | 小程序 v1.0.11 已上传、H5 已部署、生产模式已启用、SMTP 邮箱验证码已配置 |
|
||||||
| Phase 1.5: 商业化 | 📋 规划 | 冲刺版 ¥49.9/月、每日一题定时推送、PMF 验证 |
|
| Phase 1.5: 商业化 | 📋 规划 | 冲刺版 ¥49.9/月、每日一题定时推送、PMF 验证 |
|
||||||
| Phase 2: 增强 + 题库 | 📋 规划 | 50+ 校招岗位、技能缺口分析、公司真题库建设 |
|
| Phase 2: 增强 + 题库 | 📋 规划 | 50+ 校招岗位、技能缺口分析、公司真题库建设 |
|
||||||
| Phase 3: 秋招冲刺 | 📋 规划 | 高校合作、B 端服务、KOC 推广 |
|
| Phase 3: 秋招冲刺 | 📋 规划 | 高校合作、B 端服务、KOC 推广 |
|
||||||
@@ -204,6 +204,7 @@ cd zhiyin-app && npm run build:mp-weixin && node scripts/upload-mp.js
|
|||||||
```
|
```
|
||||||
MONGODB_URI=mongodb://localhost:27017/zhiyin
|
MONGODB_URI=mongodb://localhost:27017/zhiyin
|
||||||
JWT_SECRET=your-strong-secret-at-least-32-chars
|
JWT_SECRET=your-strong-secret-at-least-32-chars
|
||||||
|
NODE_ENV=production
|
||||||
PORT=3006
|
PORT=3006
|
||||||
AI_PRIMARY_KEY=xxx
|
AI_PRIMARY_KEY=xxx
|
||||||
AI_BACKUP_KEY=xxx
|
AI_BACKUP_KEY=xxx
|
||||||
@@ -214,6 +215,12 @@ WECHAT_SERIAL_NO=xxx
|
|||||||
WECHAT_PRIVATE_KEY_PATH=/path/to/apiclient_key.pem
|
WECHAT_PRIVATE_KEY_PATH=/path/to/apiclient_key.pem
|
||||||
WX_DAILY_QUESTION_TMPL=微信订阅消息模板 ID
|
WX_DAILY_QUESTION_TMPL=微信订阅消息模板 ID
|
||||||
CORS_ORIGINS=http://localhost:8888,https://zhiyin.yzrcloud.cn
|
CORS_ORIGINS=http://localhost:8888,https://zhiyin.yzrcloud.cn
|
||||||
|
EMAIL_HOST=smtp.qiye.aliyun.com
|
||||||
|
EMAIL_PORT=465
|
||||||
|
EMAIL_SECURE=true
|
||||||
|
EMAIL_USER=contact@yuzhiran.com
|
||||||
|
EMAIL_PASSWORD=xxx
|
||||||
|
EMAIL_FROM=宇之然AI磁场 <contact@yuzhiran.com>
|
||||||
WHISPER_CPP_PATH=/home/wlt/whisper.cpp # whisper.cpp 路径
|
WHISPER_CPP_PATH=/home/wlt/whisper.cpp # whisper.cpp 路径
|
||||||
WHISPER_MODEL=base # ASR 模型:tiny / base / small
|
WHISPER_MODEL=base # ASR 模型:tiny / base / small
|
||||||
WHISPER_LANGUAGE=zh # ASR 语言
|
WHISPER_LANGUAGE=zh # ASR 语言
|
||||||
@@ -228,7 +235,19 @@ VITE_APP_NAME=AI磁场
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 八、Git
|
## 八、测试 / 管理员账号
|
||||||
|
|
||||||
|
| 账号 | 密码 | 角色 | 说明 |
|
||||||
|
|------|------|------|------|
|
||||||
|
| `13701190814@139.com` | `Zhiyin2024!` | admin | 管理员,可访问管理后台 |
|
||||||
|
| `test@yzrcloud.cn` | `123456` | user | 测试账号 |
|
||||||
|
| `test@test.com` | 验证码 `123456` | admin | 旧管理员(dev 模式可用) |
|
||||||
|
|
||||||
|
管理后台路径:`/pages/admin/admin`,进入后自动验证管理员身份(`onMounted` → `doVerify`)。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 九、Git
|
||||||
|
|
||||||
- 远程仓库: `http://127.0.0.1:2999/txai-dev/zhiyin.git`(本机 Gitea,带 token 认证)
|
- 远程仓库: `http://127.0.0.1:2999/txai-dev/zhiyin.git`(本机 Gitea,带 token 认证)
|
||||||
- 默认分支: `master`
|
- 默认分支: `master`
|
||||||
@@ -236,7 +255,7 @@ VITE_APP_NAME=AI磁场
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 九、技术细节与坑
|
## 十、技术细节与坑
|
||||||
|
|
||||||
1. **DOMMatrix polyfill**: `main.ts` 顶部有 pdf-parse 所需的浏览器 API polyfill(DOMMatrix / DOMPoint),新增 PDF 相关功能时注意兼容性
|
1. **DOMMatrix polyfill**: `main.ts` 顶部有 pdf-parse 所需的浏览器 API polyfill(DOMMatrix / DOMPoint),新增 PDF 相关功能时注意兼容性
|
||||||
2. **postbuild**: `backend/package.json` 中的 `postbuild` 脚本自动复制 `certs/` 到 `dist/src/certs/`,这是微信支付证书的必要步骤
|
2. **postbuild**: `backend/package.json` 中的 `postbuild` 脚本自动复制 `certs/` 到 `dist/src/certs/`,这是微信支付证书的必要步骤
|
||||||
@@ -244,14 +263,15 @@ VITE_APP_NAME=AI磁场
|
|||||||
4. **前端 API 调用**: `zhiyin-app/src/services/api.ts` 封装了 `uni.request`,自动处理 token 注入(从 `uni.getStorageSync('token')`)和 401 过期跳转
|
4. **前端 API 调用**: `zhiyin-app/src/services/api.ts` 封装了 `uni.request`,自动处理 token 注入(从 `uni.getStorageSync('token')`)和 401 过期跳转
|
||||||
5. **前端环境判断**: `config.ts` 中使用 `// #ifdef H5` / `// #ifdef MP-WEIXIN` 条件编译区分 H5 和小程序
|
5. **前端环境判断**: `config.ts` 中使用 `// #ifdef H5` / `// #ifdef MP-WEIXIN` 条件编译区分 H5 和小程序
|
||||||
6. **API 限流**: 100 次/60 秒(在 `app.module.ts` 中配置),注意避免在定时任务和批量操作中被限
|
6. **API 限流**: 100 次/60 秒(在 `app.module.ts` 中配置),注意避免在定时任务和批量操作中被限
|
||||||
7. **验证码**: 开发模式下手机验证码固定为 `123456`(`user.service.ts` 中实现),生产环境需移除
|
7. **验证码**: 生产模式(`NODE_ENV=production`)使用真实 SMTP 发邮件验证码;非生产模式手机验证码固定为 `123456`、邮件验证码在响应中返回 `devCode`
|
||||||
8. **MongoDB**: 8 个核心集合 + 2 个分享集合
|
8. **MongoDB**: 8 个核心集合 + 2 个分享集合
|
||||||
9. **api.ts 陷阱**: 对象字面量必须在 `export const apiService = {` 或 `const apiService = { ... export default apiService` 中包裹,否则 uni-app 构建报错 `Expected ";" but found ":"`。git pull 后经常丢失这行声明,需手动补回
|
9. **api.ts 陷阱**: 对象字面量必须在 `export const apiService = {` 或 `const apiService = { ... export default apiService` 中包裹,否则 uni-app 构建报错 `Expected ";" but found ":"`。git pull 后经常丢失这行声明,需手动补回
|
||||||
10. **H5 构建 assets 清理**: `assets/` 中的旧 hash 文件不能随意删除——`index-*.js`(主 bundle)动态 import 了所有 page chunk,删除仍在引用的文件会导致浏览器 `NS_ERROR_CORRUPTED_CONTENT`
|
10. **H5 构建 assets 清理**: `assets/` 中的旧 hash 文件不能随意删除——`index-*.js`(主 bundle)动态 import 了所有 page chunk,删除仍在引用的文件会导致浏览器 `NS_ERROR_CORRUPTED_CONTENT`
|
||||||
|
11. **管理后台自动验证**: `admin.vue` 中 `onMounted` 自动调用 `doVerify()`,进入后台即检测 JWT 中 `role` 是否为 `admin`,不再需要手动点击"验证管理员身份"按钮
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 十、交付检查清单(每次实施/修改后执行)
|
## 十一、交付检查清单(每次实施/修改后执行)
|
||||||
|
|
||||||
### Step 1: 代码评审
|
### Step 1: 代码评审
|
||||||
- [ ] 是否符合现有模块模式(schema→service→controller→module)
|
- [ ] 是否符合现有模块模式(schema→service→controller→module)
|
||||||
|
|||||||
@@ -279,12 +279,19 @@ export class AdminController {
|
|||||||
const user = await this.userModel.findById(order.userId).exec()
|
const user = await this.userModel.findById(order.userId).exec()
|
||||||
if (user && user.plan === 'free') {
|
if (user && user.plan === 'free') {
|
||||||
const pricing = await this.pricingService.getConfig()
|
const pricing = await this.pricingService.getConfig()
|
||||||
const credits = pricing.plans?.growth?.credits || { interview: 999, resumeOptimize: 20, resumeDownload: 10 }
|
const planId = order.plan === 'sprint' ? 'sprint' : 'growth'
|
||||||
|
const planCfg = pricing.plans?.[planId]
|
||||||
|
const credits = planCfg?.credits || { interview: 999, resumeOptimize: 20, resumeDownload: 10 }
|
||||||
const expireAt = new Date()
|
const expireAt = new Date()
|
||||||
expireAt.setDate(expireAt.getDate() + (pricing.plans?.growth?.durationDays || VIP_DURATION_DAYS))
|
expireAt.setDate(expireAt.getDate() + (planCfg?.durationDays || VIP_DURATION_DAYS))
|
||||||
user.plan = 'growth'
|
user.plan = planId
|
||||||
user.vipExpireAt = expireAt
|
if (planId === 'sprint') {
|
||||||
await this.quotaService.setPlanQuota(order.userId, 'growth', credits)
|
user.sprintExpireAt = expireAt
|
||||||
|
user.sprintRemaining = 10
|
||||||
|
} else {
|
||||||
|
user.vipExpireAt = expireAt
|
||||||
|
}
|
||||||
|
await this.quotaService.setPlanQuota(order.userId, planId, credits)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
const pricing = await this.pricingService.getConfig()
|
const pricing = await this.pricingService.getConfig()
|
||||||
@@ -302,6 +309,46 @@ export class AdminController {
|
|||||||
return { order, wxResult }
|
return { order, wxResult }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 订单详情(含用户信息) */
|
||||||
|
@Get('order/:outTradeNo')
|
||||||
|
async getOrderDetail(@Param('outTradeNo') outTradeNo: string) {
|
||||||
|
const order = await this.orderModel.findOne({ outTradeNo }).lean().exec()
|
||||||
|
if (!order) throw new HttpException('订单不存在', HttpStatus.NOT_FOUND)
|
||||||
|
const user = await this.userModel.findById(order.userId).select('phone nickname plan').lean().exec()
|
||||||
|
return { order, user }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 发起退款 */
|
||||||
|
@Post('order/refund')
|
||||||
|
async refundOrder(@Body('outTradeNo') outTradeNo: string, @Body('amount') amount?: number, @Body('reason') reason?: string) {
|
||||||
|
const order = await this.orderModel.findOne({ outTradeNo }).exec()
|
||||||
|
if (!order) throw new HttpException('订单不存在', HttpStatus.NOT_FOUND)
|
||||||
|
if (order.status !== 'success') throw new HttpException('仅支付成功的订单可退款', HttpStatus.BAD_REQUEST)
|
||||||
|
if (order.refundAmount && order.refundAmount > 0) throw new HttpException('该订单已退款', HttpStatus.BAD_REQUEST)
|
||||||
|
|
||||||
|
const result = await this.wechatPay.refund(outTradeNo, order.amount, amount || order.amount, reason)
|
||||||
|
const refundId = result?.refund_id || ''
|
||||||
|
|
||||||
|
order.status = 'refunded'
|
||||||
|
order.refundAmount = amount || order.amount
|
||||||
|
order.refundedAt = new Date()
|
||||||
|
order.refundReason = reason || ''
|
||||||
|
order.refundId = refundId
|
||||||
|
await order.save()
|
||||||
|
|
||||||
|
return { success: true, refundId }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 查询微信侧退款状态 */
|
||||||
|
@Get('order/refund/:outTradeNo')
|
||||||
|
async queryRefund(@Param('outTradeNo') outTradeNo: string) {
|
||||||
|
const order = await this.orderModel.findOne({ outTradeNo }).lean().exec()
|
||||||
|
if (!order) throw new HttpException('订单不存在', HttpStatus.NOT_FOUND)
|
||||||
|
if (!order.refundId) return { localStatus: order.status, message: '无微信退款单号' }
|
||||||
|
const wxResult = await this.wechatPay.queryRefund(order.refundId)
|
||||||
|
return { localStatus: order.status, wxRefund: wxResult }
|
||||||
|
}
|
||||||
|
|
||||||
@Get('config')
|
@Get('config')
|
||||||
async getConfig() {
|
async getConfig() {
|
||||||
const cfg = await this.configModel.findOne({ key: 'site_config' }).exec()
|
const cfg = await this.configModel.findOne({ key: 'site_config' }).exec()
|
||||||
@@ -362,8 +409,8 @@ const DEFAULT_CONFIG = {
|
|||||||
optimize: { dailyFreeLimit: 2 },
|
optimize: { dailyFreeLimit: 2 },
|
||||||
price: { monthly: 1990 },
|
price: { monthly: 1990 },
|
||||||
plans: {
|
plans: {
|
||||||
free: { name: '免费版', price: 0, features: ['AI 模拟面试 1 次(体验)', '每场最多 5 轮 AI 对话', '基础面试报告', '简历优化(限 3 次)'] },
|
free: { name: '免费版', price: 0, features: ['AI 模拟面试 1 次(体验)', '基础面试报告', '通用题库随机出题', '简历优化(限 3 次免费)'] },
|
||||||
growth: { name: '成长版', price: 1990, features: ['免费版全部权益', 'AI 数字人面试无限次', '每场最多 10 轮 AI 对话', '详细面试报告(四维评分)', '进步轨迹雷达图 + 打卡', '参考回答思路', '公司真题库', '简历优化 20 次/月', '简历下载 10 次/月'] },
|
growth: { name: '成长版', price: 1990, features: ['免费版全部权益', 'AI 数字人面试无限次', '详细面试报告(四维评分)', '进步轨迹雷达图 + 打卡', '每日一题', '参考回答思路', '公司真题库', '简历优化 20 次/月', '简历下载 10 次/月'] },
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -375,12 +422,12 @@ const DEFAULT_PRICING = {
|
|||||||
growth: {
|
growth: {
|
||||||
price: 1990, durationDays: 30,
|
price: 1990, durationDays: 30,
|
||||||
credits: { interview: 999, resumeOptimize: 20, resumeDownload: 10 },
|
credits: { interview: 999, resumeOptimize: 20, resumeDownload: 10 },
|
||||||
features: ['免费版全部权益', 'AI 数字人面试无限次', '详细面试报告(四维评分)', '进步轨迹雷达图 + 打卡', '每日一题推送', '参考回答思路', '公司真题库', '简历优化 20 次/月', '简历下载 10 次/月'],
|
features: ['免费版全部权益', 'AI 数字人面试无限次', '详细面试报告(四维评分)', '进步轨迹雷达图 + 打卡', '每日一题', '参考回答思路', '公司真题库', '简历优化 20 次/月', '简历下载 10 次/月'],
|
||||||
},
|
},
|
||||||
sprint: {
|
sprint: {
|
||||||
price: 4990, durationDays: 30,
|
price: 4990, durationDays: 30,
|
||||||
credits: { interview: 999, resumeOptimize: 50, resumeDownload: 30 },
|
credits: { interview: 999, resumeOptimize: 50, resumeDownload: 30 },
|
||||||
features: ['成长版全部权益', 'AI 语音分析(语气词/语速检测)', '技能缺口分析报告', '学习路径推荐', '真人导师 1v1 点评(每月 1 次)', '简历精修(每月 1 次)', '内推优先', '简历优化 50 次/月', '简历下载 30 次/月'],
|
features: ['成长版全部权益', 'AI 语音分析(语气词/语速检测)', '技能缺口分析报告', '公司真题库精选', '简历优化 50 次/月', '简历下载 30 次/月'],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,65 +22,74 @@ export class AiService {
|
|||||||
|
|
||||||
private readonly backupUrl = process.env.AI_BACKUP_URL || "https://integrate.api.nvidia.com/v1"
|
private readonly backupUrl = process.env.AI_BACKUP_URL || "https://integrate.api.nvidia.com/v1"
|
||||||
private readonly backupKey = process.env.AI_BACKUP_KEY || ""
|
private readonly backupKey = process.env.AI_BACKUP_KEY || ""
|
||||||
private readonly backupModel = process.env.AI_BACKUP_MODEL || "stepfun-ai/step-3.5-flash"
|
private readonly backupModel = process.env.AI_BACKUP_MODEL || "meta/llama-3.1-8b-instruct"
|
||||||
|
|
||||||
async call(options: AiCallOptions): Promise<string> {
|
async call(options: AiCallOptions): Promise<string> {
|
||||||
const { systemPrompt, userMessage, temperature = 0.7, maxTokens = 2048 } = options
|
const { systemPrompt, userMessage, temperature = 0.7, maxTokens = 2048 } = options
|
||||||
|
|
||||||
// Try primary AI (deepseek-v4-flash on sensenova)
|
// Try primary AI (deepseek-v4-flash on sensenova)
|
||||||
try {
|
try {
|
||||||
const result = await this.callApi(this.primaryUrl, this.primaryKey, this.primaryModel, systemPrompt, userMessage, temperature, maxTokens)
|
const result = await this.callApi(this.primaryUrl, this.primaryKey, this.primaryModel, systemPrompt, userMessage, temperature, maxTokens, 60000)
|
||||||
if (result) return result
|
if (result) return result
|
||||||
|
// Primary returned empty content (thinking model exhausted tokens); retry with more tokens
|
||||||
|
const retry = await this.callApi(this.primaryUrl, this.primaryKey, this.primaryModel, systemPrompt, userMessage, temperature, Math.min(maxTokens * 2, 4096), 60000)
|
||||||
|
if (retry) return retry
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
this.logger.warn(`Primary AI failed: ${(e as Error).message}, trying primary fallback...`)
|
this.logger.warn(`Primary AI failed: ${(e as Error).message}, trying primary fallback...`)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Try primary fallback model (sensenova-6.7-flash-lite, same provider)
|
// Try primary fallback model (sensenova-6.7-flash-lite, same provider)
|
||||||
try {
|
try {
|
||||||
const result = await this.callApi(this.primaryUrl, this.primaryKey, this.primaryFallbackModel, systemPrompt, userMessage, temperature, maxTokens)
|
const result = await this.callApi(this.primaryUrl, this.primaryKey, this.primaryFallbackModel, systemPrompt, userMessage, temperature, maxTokens, 60000)
|
||||||
if (result) return result
|
if (result) return result
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
this.logger.warn(`Primary fallback AI also failed: ${(e as Error).message}, trying backup...`)
|
this.logger.warn(`Primary fallback AI also failed: ${(e as Error).message}, trying backup...`)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Try backup AI (NVIDIA)
|
// Try backup AI (NVIDIA - meta/llama-3.1-8b-instruct)
|
||||||
try {
|
try {
|
||||||
const result = await this.callApi(this.backupUrl, this.backupKey, this.backupModel, systemPrompt, userMessage, temperature, maxTokens)
|
const result = await this.callApi(this.backupUrl, this.backupKey, this.backupModel, systemPrompt, userMessage, temperature, Math.max(maxTokens, 2048), 120000)
|
||||||
if (result) return result
|
if (result) return result
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
this.logger.warn(`Backup AI also failed: ${(e as Error).message}`)
|
this.logger.warn(`Backup AI also failed: ${(e as Error).message}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Final fallback
|
throw new Error("AI 服务暂时不可用,请稍后重试")
|
||||||
throw new Error("AI \u670d\u52a1\u6682\u65f6\u4e0d\u53ef\u7528\uff0c\u8bf7\u7a0d\u540e\u91cd\u8bd5")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private async callApi(
|
private async callApi(
|
||||||
baseUrl: string, apiKey: string, model: string,
|
baseUrl: string, apiKey: string, model: string,
|
||||||
systemPrompt: string, userMessage: string,
|
systemPrompt: string, userMessage: string,
|
||||||
temperature: number, maxTokens: number,
|
temperature: number, maxTokens: number, timeout: number,
|
||||||
): Promise<string | null> {
|
): Promise<string | null> {
|
||||||
const res = await axios.post(
|
try {
|
||||||
`${baseUrl}/chat/completions`,
|
const res = await axios.post(
|
||||||
{
|
`${baseUrl}/chat/completions`,
|
||||||
model,
|
{
|
||||||
messages: [
|
model,
|
||||||
{ role: "system", content: systemPrompt },
|
messages: [
|
||||||
{ role: "user", content: userMessage },
|
{ role: "system", content: systemPrompt },
|
||||||
],
|
{ role: "user", content: userMessage },
|
||||||
temperature,
|
],
|
||||||
max_tokens: maxTokens,
|
temperature,
|
||||||
},
|
max_tokens: maxTokens,
|
||||||
{
|
|
||||||
headers: {
|
|
||||||
"Authorization": `Bearer ${apiKey}`,
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
},
|
},
|
||||||
timeout: 60000,
|
{
|
||||||
httpsAgent: httpAgent,
|
headers: {
|
||||||
transitional: { clarifyTimeoutError: true },
|
"Authorization": `Bearer ${apiKey}`,
|
||||||
},
|
"Content-Type": "application/json",
|
||||||
)
|
},
|
||||||
return res.data?.choices?.[0]?.message?.content || null
|
timeout,
|
||||||
|
httpsAgent: httpAgent,
|
||||||
|
transitional: { clarifyTimeoutError: true },
|
||||||
|
},
|
||||||
|
)
|
||||||
|
return res.data?.choices?.[0]?.message?.content || null
|
||||||
|
} catch (e: any) {
|
||||||
|
if (e.code === 'ECONNABORTED') {
|
||||||
|
this.logger.warn(`AI call timeout (${timeout}ms): ${model}`)
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -54,6 +54,9 @@ export class PaymentOrder {
|
|||||||
|
|
||||||
@Prop()
|
@Prop()
|
||||||
refundReason?: string
|
refundReason?: string
|
||||||
|
|
||||||
|
@Prop()
|
||||||
|
refundId?: string // 微信退款单号
|
||||||
}
|
}
|
||||||
|
|
||||||
export const PaymentOrderSchema = SchemaFactory.createForClass(PaymentOrder)
|
export const PaymentOrderSchema = SchemaFactory.createForClass(PaymentOrder)
|
||||||
|
|||||||
@@ -150,8 +150,8 @@ export class PaymentController {
|
|||||||
const wechatSignature = req.headers['wechatpay-signature'] || ''
|
const wechatSignature = req.headers['wechatpay-signature'] || ''
|
||||||
const wechatTimestamp = req.headers['wechatpay-timestamp'] || ''
|
const wechatTimestamp = req.headers['wechatpay-timestamp'] || ''
|
||||||
const wechatNonce = req.headers['wechatpay-nonce'] || ''
|
const wechatNonce = req.headers['wechatpay-nonce'] || ''
|
||||||
const decrypted = this.wechatPay.verifyAndDecrypt(body, wechatSignature, wechatTimestamp, wechatNonce)
|
const decrypted = this.wechatPay.verifyAndDecrypt(body, wechatSignature, wechatTimestamp, wechatNonce, true)
|
||||||
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
|
||||||
|
|||||||
@@ -121,40 +121,92 @@ export class WechatPayService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** 验证并解密回调通知 */
|
/** 验证并解密回调通知 */
|
||||||
verifyAndDecrypt(body: any, wechatSignature: string, wechatTimestamp: string, wechatNonce: string) {
|
verifyAndDecrypt(body: any, wechatSignature: string, wechatTimestamp: string, wechatNonce: string, returnRaw?: boolean) {
|
||||||
// 1. 验签
|
let verified = false
|
||||||
const message = `${wechatTimestamp}\n${wechatNonce}\n${JSON.stringify(body)}\n`
|
const message = `${wechatTimestamp}\n${wechatNonce}\n${JSON.stringify(body)}\n`
|
||||||
const certDir = path.resolve(__dirname, '../../certs')
|
const certDir = path.resolve(__dirname, '../../certs')
|
||||||
if (!fs.existsSync(certDir)) {
|
const pemPath = path.join(certDir, 'pub_key.pem')
|
||||||
this.logger.error(`证书目录不存在: ${certDir}`)
|
if (fs.existsSync(pemPath)) {
|
||||||
|
const platformCert = fs.readFileSync(pemPath, 'utf8')
|
||||||
|
const verify = crypto.createVerify('RSA-SHA256').update(message)
|
||||||
|
verified = verify.verify(platformCert, wechatSignature, 'base64')
|
||||||
|
} else {
|
||||||
|
this.logger.warn('pub_key.pem 不存在,跳过验签')
|
||||||
|
}
|
||||||
|
if (!verified) {
|
||||||
|
this.logger.warn(`微信支付回调验签失败 — 请从商户平台下载最新公钥覆盖 pub_key.pem (https://pay.weixin.qq.com/)`)
|
||||||
|
}
|
||||||
|
// 2. 解密 resource(解密不依赖公钥,即使验签失败也尝试解密)
|
||||||
|
try {
|
||||||
|
const resource = body.resource
|
||||||
|
const ciphertext = Buffer.from(resource.ciphertext, 'base64')
|
||||||
|
const associatedData = resource.associated_data || ''
|
||||||
|
const nonce = resource.nonce
|
||||||
|
const key = API_V3_KEY
|
||||||
|
if (!key) throw new Error('WX_API_V3_KEY 未配置')
|
||||||
|
const authTag = ciphertext.subarray(ciphertext.length - 16)
|
||||||
|
const data = ciphertext.subarray(0, ciphertext.length - 16)
|
||||||
|
const decipher = crypto.createDecipheriv('aes-256-gcm', Buffer.from(key), nonce)
|
||||||
|
decipher.setAAD(Buffer.from(associatedData))
|
||||||
|
decipher.setAuthTag(authTag)
|
||||||
|
const decrypted = decipher.update(data) + decipher.final('utf8')
|
||||||
|
const parsed = JSON.parse(decrypted)
|
||||||
|
if (returnRaw) return parsed
|
||||||
|
if (!verified) return null
|
||||||
|
return parsed
|
||||||
|
} catch (e) {
|
||||||
|
this.logger.error(`解密回调 resource 失败: ${e.message}`)
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
const platformCert = fs.readFileSync(path.join(certDir, 'pub_key.pem'), 'utf8')
|
|
||||||
const verify = crypto.createVerify('RSA-SHA256').update(message)
|
|
||||||
const isValid = verify.verify(platformCert, wechatSignature, 'base64')
|
|
||||||
if (!isValid) {
|
|
||||||
this.logger.warn('微信支付回调验签失败')
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
// 2. 解密 resource
|
|
||||||
const resource = body.resource
|
|
||||||
const ciphertext = Buffer.from(resource.ciphertext, 'base64')
|
|
||||||
const associatedData = resource.associated_data || ''
|
|
||||||
const nonce = resource.nonce
|
|
||||||
const key = API_V3_KEY
|
|
||||||
if (!key) throw new Error('WX_API_V3_KEY 未配置')
|
|
||||||
// AES-256-GCM 解密
|
|
||||||
const authTag = ciphertext.subarray(ciphertext.length - 16)
|
|
||||||
const data = ciphertext.subarray(0, ciphertext.length - 16)
|
|
||||||
const decipher = crypto.createDecipheriv('aes-256-gcm', Buffer.from(key), nonce)
|
|
||||||
decipher.setAAD(Buffer.from(associatedData))
|
|
||||||
decipher.setAuthTag(authTag)
|
|
||||||
const decrypted = decipher.update(data) + decipher.final('utf8')
|
|
||||||
return JSON.parse(decrypted)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 查询订单 */
|
/** 查询订单 */
|
||||||
async queryOrder(outTradeNo: string) {
|
async queryOrder(outTradeNo: string) {
|
||||||
return this.request('GET', `/v3/pay/transactions/out-trade-no/${outTradeNo}?mchid=${MCHID}`)
|
return this.request('GET', `/v3/pay/transactions/out-trade-no/${outTradeNo}?mchid=${MCHID}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 退款 */
|
||||||
|
async refund(outTradeNo: string, total: number, refundAmount?: number, reason?: string) {
|
||||||
|
const body: any = {
|
||||||
|
out_trade_no: outTradeNo,
|
||||||
|
out_refund_no: `RF${Date.now()}`,
|
||||||
|
amount: { refund: refundAmount || total, total, currency: 'CNY' },
|
||||||
|
}
|
||||||
|
if (reason) body.reason = reason
|
||||||
|
return this.request('POST', '/v3/refund/domestic/refunds', body)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 查询退款 */
|
||||||
|
async queryRefund(outRefundNo: string) {
|
||||||
|
return this.request('GET', `/v3/refund/domestic/refunds/${outRefundNo}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 下载微信平台证书(首次部署/证书过期时调用) */
|
||||||
|
async downloadPlatformCerts(): Promise<string[]> {
|
||||||
|
if (!API_V3_KEY) throw new Error('WX_API_V3_KEY 未配置')
|
||||||
|
const certs = await this.request('GET', '/v3/certificates')
|
||||||
|
const downloaded: string[] = []
|
||||||
|
const certDir = path.resolve(__dirname, '../../certs')
|
||||||
|
if (!fs.existsSync(certDir)) fs.mkdirSync(certDir, { recursive: true })
|
||||||
|
|
||||||
|
for (const item of certs.data || []) {
|
||||||
|
const { serial_no, effective_time, expire_time, encrypt_certificate } = item
|
||||||
|
const { algorithm, nonce, associated_data, ciphertext } = encrypt_certificate
|
||||||
|
if (algorithm !== 'AEAD_AES_256_GCM' || !nonce) continue
|
||||||
|
|
||||||
|
const cipherBuf = Buffer.from(ciphertext, 'base64')
|
||||||
|
const authTag = cipherBuf.subarray(cipherBuf.length - 16)
|
||||||
|
const data = cipherBuf.subarray(0, cipherBuf.length - 16)
|
||||||
|
const decipher = crypto.createDecipheriv('aes-256-gcm', Buffer.from(API_V3_KEY), nonce)
|
||||||
|
decipher.setAAD(Buffer.from(associated_data))
|
||||||
|
decipher.setAuthTag(authTag)
|
||||||
|
const decrypted = decipher.update(data) + decipher.final('utf8')
|
||||||
|
|
||||||
|
const pemPath = path.join(certDir, 'pub_key.pem')
|
||||||
|
fs.writeFileSync(pemPath, decrypted)
|
||||||
|
downloaded.push(serial_no)
|
||||||
|
this.logger.log(`微信平台证书已更新: ${serial_no}, 有效期至 ${expire_time}`)
|
||||||
|
}
|
||||||
|
return downloaded
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+28
-1
@@ -1,7 +1,34 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { onLaunch, onShow, onHide } from '@dcloudio/uni-app'
|
import { onLaunch, onShow, onHide } from '@dcloudio/uni-app'
|
||||||
|
|
||||||
onLaunch(() => { console.log('职引 App launched') })
|
onLaunch(() => {
|
||||||
|
console.log('职引 App launched')
|
||||||
|
// #ifdef MP-WEIXIN
|
||||||
|
initPrivacy()
|
||||||
|
// #endif
|
||||||
|
})
|
||||||
|
|
||||||
|
// #ifdef MP-WEIXIN
|
||||||
|
function initPrivacy() {
|
||||||
|
if (wx.onNeedPrivacyAuthorization) {
|
||||||
|
wx.onNeedPrivacyAuthorization((resolve) => {
|
||||||
|
uni.showModal({
|
||||||
|
title: '隐私政策授权',
|
||||||
|
content: '请仔细阅读并同意《用户服务协议》和《隐私政策》后再使用本应用。您的个人信息将仅用于求职服务。',
|
||||||
|
confirmText: '同意',
|
||||||
|
cancelText: '拒绝',
|
||||||
|
success: (res) => {
|
||||||
|
if (res.confirm) {
|
||||||
|
resolve({ event: 'agree' })
|
||||||
|
} else {
|
||||||
|
resolve({ event: 'disagree' })
|
||||||
|
}
|
||||||
|
},
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// #endif
|
||||||
onShow(() => { console.log('职引 App shown') })
|
onShow(() => { console.log('职引 App shown') })
|
||||||
onHide(() => { console.log('职引 App hidden') })
|
onHide(() => { console.log('职引 App hidden') })
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
{
|
{
|
||||||
"name": "宇之然AI磁场",
|
"name": "宇之然AI磁场",
|
||||||
"appid": "__UNI__DEV__",
|
"appid": "__UNI__DEV__",
|
||||||
"versionName": "1.0.11",
|
"versionName": "1.0.13",
|
||||||
"versionCode": "111",
|
"versionCode": "113",
|
||||||
"description": "职引 - 宇之然AI磁场旗下AI模拟面试平台,提供AI面试官模拟练习、简历智能优化、大厂面经题库,助你轻松应对校招面试。",
|
"description": "职引 - 宇之然AI磁场旗下AI模拟面试平台,提供AI面试官模拟练习、简历智能优化、大厂面经题库,助你轻松应对校招面试。",
|
||||||
"h5": {
|
"h5": {
|
||||||
"title": "职引 - AI模拟面试 | 宇之然AI磁场",
|
"title": "职引 - AI模拟面试 | 宇之然AI磁场",
|
||||||
@@ -13,7 +13,8 @@
|
|||||||
"mp-weixin": {
|
"mp-weixin": {
|
||||||
"appid": "wxf466b3c3bc411ffc",
|
"appid": "wxf466b3c3bc411ffc",
|
||||||
"setting": {
|
"setting": {
|
||||||
"urlCheck": false
|
"urlCheck": false,
|
||||||
|
"__usePrivacyCheck__": true
|
||||||
},
|
},
|
||||||
"usingComponents": true
|
"usingComponents": true
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -142,8 +142,10 @@
|
|||||||
{{ o.status === 'success' ? '已支付' : o.status === 'refunded' ? '已退款' : '待支付' }}
|
{{ o.status === 'success' ? '已支付' : o.status === 'refunded' ? '已退款' : '待支付' }}
|
||||||
</view>
|
</view>
|
||||||
<text class="order-time">{{ o.createdAt ? o.createdAt.slice(0,16).replace('T',' ') : '--' }}</text>
|
<text class="order-time">{{ o.createdAt ? o.createdAt.slice(0,16).replace('T',' ') : '--' }}</text>
|
||||||
<view class="order-actions" v-if="o.status === 'pending'">
|
<view class="order-actions">
|
||||||
<text class="sync-btn" @click="syncOrder(o.outTradeNo)">同步</text>
|
<text class="sync-btn" v-if="o.status === 'pending'" @click="syncOrder(o.outTradeNo)">同步</text>
|
||||||
|
<text class="refund-btn" v-if="o.status === 'success'" @click="openRefundModal(o)">退款</text>
|
||||||
|
<text class="sync-btn" v-if="o.status === 'refunded'" @click="queryRefund(o.outTradeNo)">查询</text>
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
@@ -338,6 +340,29 @@
|
|||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
|
|
||||||
|
<!-- 退款弹窗 -->
|
||||||
|
<view class="modal-mask" v-if="refundModal.show" @click="closeRefundModal">
|
||||||
|
<view class="modal-content" @click.stop>
|
||||||
|
<text class="modal-title">退款 - {{ refundModal.order?.outTradeNo }}</text>
|
||||||
|
<view class="cfg-row">
|
||||||
|
<text>订单金额</text>
|
||||||
|
<text class="cfg-val">¥{{ ((refundModal.order?.amount || 0) / 100).toFixed(1) }}</text>
|
||||||
|
</view>
|
||||||
|
<view class="cfg-row">
|
||||||
|
<text>退款金额(元)</text>
|
||||||
|
<input class="cfg-input" type="digit" v-model.number="refundAmount" :placeholder="((refundModal.order?.amount || 0) / 100).toFixed(1)" />
|
||||||
|
</view>
|
||||||
|
<view class="cfg-row">
|
||||||
|
<text>退款原因</text>
|
||||||
|
<input class="cfg-input" style="width:300rpx" v-model="refundReason" placeholder="选填" />
|
||||||
|
</view>
|
||||||
|
<view class="modal-actions">
|
||||||
|
<button class="modal-btn cancel" @click="closeRefundModal">取消</button>
|
||||||
|
<button class="modal-btn confirm" style="background:#EF4444" @click="doRefund">确认退款</button>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
<!-- 管理员 -->
|
<!-- 管理员 -->
|
||||||
<view v-if="tab === 'admins'" class="section">
|
<view v-if="tab === 'admins'" class="section">
|
||||||
<view class="search-bar">
|
<view class="search-bar">
|
||||||
@@ -368,7 +393,7 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { ref, computed } from 'vue'
|
import { ref, computed, onMounted, reactive } from 'vue'
|
||||||
import { api, API_ENDPOINTS } from '../../config'
|
import { api, API_ENDPOINTS } from '../../config'
|
||||||
|
|
||||||
const verified = ref(false)
|
const verified = ref(false)
|
||||||
@@ -448,6 +473,67 @@ const creditTypes = ref([
|
|||||||
{ key: 'shareCredits', label: '分享积分', value: 0 },
|
{ key: 'shareCredits', label: '分享积分', value: 0 },
|
||||||
])
|
])
|
||||||
|
|
||||||
|
// Refund modal
|
||||||
|
const refundModal = ref({ show: false, order: null })
|
||||||
|
const refundAmount = ref(0)
|
||||||
|
const refundReason = ref('')
|
||||||
|
|
||||||
|
const openRefundModal = (order) => {
|
||||||
|
refundModal.value = { show: true, order }
|
||||||
|
refundAmount.value = order.amount / 100
|
||||||
|
refundReason.value = ''
|
||||||
|
}
|
||||||
|
|
||||||
|
const closeRefundModal = () => {
|
||||||
|
refundModal.value = { show: false, order: null }
|
||||||
|
}
|
||||||
|
|
||||||
|
const doRefund = async () => {
|
||||||
|
const order = refundModal.value.order
|
||||||
|
if (!order) return
|
||||||
|
uni.showModal({
|
||||||
|
title: '确认退款', content: `确定对订单 ${order.outTradeNo} 退款 ¥${refundAmount.value.toFixed(1)}?`,
|
||||||
|
success: async (r) => {
|
||||||
|
if (!r.confirm) return
|
||||||
|
try {
|
||||||
|
const res = await apiAdmin('/order/refund', {
|
||||||
|
method: 'POST',
|
||||||
|
body: { outTradeNo: order.outTradeNo, amount: Math.round(refundAmount.value * 100), reason: refundReason.value },
|
||||||
|
})
|
||||||
|
if (res.statusCode === 200) {
|
||||||
|
uni.showToast({ title: '退款成功', icon: 'success' })
|
||||||
|
closeRefundModal()
|
||||||
|
loadOrders()
|
||||||
|
} else {
|
||||||
|
uni.showToast({ title: res.data?.message || '退款失败', icon: 'none' })
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
uni.showToast({ title: '退款失败', icon: 'none' })
|
||||||
|
}
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const queryRefund = async (outTradeNo) => {
|
||||||
|
uni.showToast({ title: '查询中...', icon: 'none' })
|
||||||
|
try {
|
||||||
|
const res = await apiAdmin('/order/refund/' + outTradeNo)
|
||||||
|
if (res.statusCode === 200) {
|
||||||
|
const wx = res.data.wxRefund
|
||||||
|
if (wx) {
|
||||||
|
uni.showModal({
|
||||||
|
title: '退款状态',
|
||||||
|
content: `微信状态: ${wx.status || '--'}\n退款金额: ¥${(wx.amount?.refund || 0) / 100}\n建议以微信侧为准`,
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
uni.showToast({ title: '本地状态: ' + (res.data.localStatus || '--'), icon: 'none' })
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
uni.showToast({ title: '查询失败', icon: 'none' })
|
||||||
|
}
|
||||||
|
} catch { uni.showToast({ title: '查询失败', icon: 'none' }) }
|
||||||
|
}
|
||||||
|
|
||||||
const token = () => uni.getStorageSync('token') || ''
|
const token = () => uni.getStorageSync('token') || ''
|
||||||
|
|
||||||
const apiAdmin = (path, opts = {}) => {
|
const apiAdmin = (path, opts = {}) => {
|
||||||
@@ -777,6 +863,8 @@ const doAdjustCredits = async () => {
|
|||||||
loadUsers()
|
loadUsers()
|
||||||
} catch { uni.showToast({ title: '调整失败', icon: 'none' }) }
|
} catch { uni.showToast({ title: '调整失败', icon: 'none' }) }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
onMounted(() => { doVerify() })
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
@@ -858,6 +946,7 @@ const doAdjustCredits = async () => {
|
|||||||
.order-time { font-size: 20rpx; color: var(--color-text-tertiary); flex: 1; text-align: right; }
|
.order-time { font-size: 20rpx; color: var(--color-text-tertiary); flex: 1; text-align: right; }
|
||||||
.order-actions { }
|
.order-actions { }
|
||||||
.sync-btn { font-size: 20rpx; color: var(--color-primary); padding: 4rpx 12rpx; border: 2rpx solid var(--color-primary); border-radius: var(--radius-round); }
|
.sync-btn { font-size: 20rpx; color: var(--color-primary); padding: 4rpx 12rpx; border: 2rpx solid var(--color-primary); border-radius: var(--radius-round); }
|
||||||
|
.refund-btn { font-size: 20rpx; color: #EF4444; padding: 4rpx 12rpx; border: 2rpx solid #EF4444; border-radius: var(--radius-round); }
|
||||||
.config-card { background: #FFF; border-radius: var(--radius-sm); padding: 20rpx; margin-bottom: 12rpx; }
|
.config-card { background: #FFF; border-radius: var(--radius-sm); padding: 20rpx; margin-bottom: 12rpx; }
|
||||||
.cfg-title { font-size: 24rpx; font-weight: 600; color: var(--color-text); margin-bottom: 12rpx; }
|
.cfg-title { font-size: 24rpx; font-weight: 600; color: var(--color-text); margin-bottom: 12rpx; }
|
||||||
.cfg-row { display: flex; justify-content: space-between; padding: 8rpx 0; font-size: 22rpx; color: var(--color-text-secondary); align-items: center; }
|
.cfg-row { display: flex; justify-content: space-between; padding: 8rpx 0; font-size: 22rpx; color: var(--color-text-secondary); align-items: center; }
|
||||||
|
|||||||
@@ -87,15 +87,18 @@
|
|||||||
<view class="card" v-if="mainTab === 'wechat' && isMp">
|
<view class="card" v-if="mainTab === 'wechat' && isMp">
|
||||||
<text class="card-title">微信一键登录</text>
|
<text class="card-title">微信一键登录</text>
|
||||||
<text class="card-sub">授权后自动创建账号</text>
|
<text class="card-sub">授权后自动创建账号</text>
|
||||||
<button class="login-btn wx-btn" @click="doWxLogin">{{ wxLoading ? '登录中...' : '微信一键登录' }}</button>
|
<button class="login-btn wx-btn" :disabled="wxLoading" @click="doWxLogin">{{ wxLoading ? '登录中...' : '微信一键登录' }}</button>
|
||||||
</view>
|
</view>
|
||||||
|
|
||||||
<!-- 法律声明 -->
|
<!-- 法律声明 - 用户自主勾选同意 -->
|
||||||
<view class="legal">
|
<view class="legal">
|
||||||
<text class="legal-text">登录即表示同意</text>
|
<label class="agree-label">
|
||||||
<text class="legal-link" @click="goAgreement">《用户协议》</text>
|
<checkbox :checked="agreed" @tap="agreed = !agreed" color="#4F46E5" style="transform:scale(0.85)" />
|
||||||
<text class="legal-text">和</text>
|
<text class="agree-text">我已阅读并同意</text>
|
||||||
<text class="legal-link" @click="goPrivacy">《隐私政策》</text>
|
<text class="legal-link" @tap.stop="goAgreement">《用户服务协议》</text>
|
||||||
|
<text class="agree-text">和</text>
|
||||||
|
<text class="legal-link" @tap.stop="goPrivacy">《隐私政策》</text>
|
||||||
|
</label>
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
|
|
||||||
@@ -117,6 +120,8 @@
|
|||||||
import { ref, computed, onMounted, onBeforeUnmount } from 'vue'
|
import { ref, computed, onMounted, onBeforeUnmount } from 'vue'
|
||||||
import { api } from '../../config'
|
import { api } from '../../config'
|
||||||
|
|
||||||
|
const agreed = ref(false)
|
||||||
|
|
||||||
const mainTab = ref('login')
|
const mainTab = ref('login')
|
||||||
const loginMode = ref('password') // 'password' | 'code'
|
const loginMode = ref('password') // 'password' | 'code'
|
||||||
const isMp = ref(false)
|
const isMp = ref(false)
|
||||||
@@ -151,6 +156,10 @@ onBeforeUnmount(() => { if (timer) { clearTimeout(timer); timer = null } })
|
|||||||
|
|
||||||
// 辅助
|
// 辅助
|
||||||
const showToast = (title, icon = 'none') => uni.showToast({ title, icon })
|
const showToast = (title, icon = 'none') => uni.showToast({ title, icon })
|
||||||
|
const checkAgreed = () => {
|
||||||
|
if (!agreed.value) { showToast('请阅读并同意《用户服务协议》和《隐私政策》'); return false }
|
||||||
|
return true
|
||||||
|
}
|
||||||
const loginSuccess = (data) => {
|
const loginSuccess = (data) => {
|
||||||
uni.setStorageSync('token', data.token)
|
uni.setStorageSync('token', data.token)
|
||||||
if (data.user) uni.setStorageSync('userInfo', JSON.stringify(data.user))
|
if (data.user) uni.setStorageSync('userInfo', JSON.stringify(data.user))
|
||||||
@@ -160,7 +169,7 @@ const loginSuccess = (data) => {
|
|||||||
|
|
||||||
// ====== 密码登录 ======
|
// ====== 密码登录 ======
|
||||||
const doPasswordLogin = async () => {
|
const doPasswordLogin = async () => {
|
||||||
if (!canPasswordLogin.value) return
|
if (!canPasswordLogin.value || !checkAgreed()) return
|
||||||
pwdLoading.value = true
|
pwdLoading.value = true
|
||||||
try {
|
try {
|
||||||
const res = await uni.request({
|
const res = await uni.request({
|
||||||
@@ -192,6 +201,9 @@ const sendEmailCode = () => {
|
|||||||
emailSent.value = true
|
emailSent.value = true
|
||||||
showToast('验证码已发送', 'success')
|
showToast('验证码已发送', 'success')
|
||||||
startCooldown()
|
startCooldown()
|
||||||
|
if (res.data?.devCode) {
|
||||||
|
uni.showModal({ title: '开发模式', content: `验证码:${res.data.devCode}`, showCancel: false })
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
const msg = (res.data && res.data.message) || '发送失败'
|
const msg = (res.data && res.data.message) || '发送失败'
|
||||||
showToast(msg)
|
showToast(msg)
|
||||||
@@ -219,7 +231,7 @@ const startCooldown = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const doEmailLogin = async () => {
|
const doEmailLogin = async () => {
|
||||||
if (!emailCode.value) return
|
if (!emailCode.value || !checkAgreed()) return
|
||||||
emailLoading.value = true
|
emailLoading.value = true
|
||||||
try {
|
try {
|
||||||
const res = await uni.request({
|
const res = await uni.request({
|
||||||
@@ -242,7 +254,7 @@ const doEmailLogin = async () => {
|
|||||||
|
|
||||||
// ====== 注册 ======
|
// ====== 注册 ======
|
||||||
const doRegister = async () => {
|
const doRegister = async () => {
|
||||||
if (!canRegister.value) return
|
if (!canRegister.value || !checkAgreed()) return
|
||||||
regLoading.value = true
|
regLoading.value = true
|
||||||
try {
|
try {
|
||||||
const res = await uni.request({
|
const res = await uni.request({
|
||||||
@@ -284,6 +296,7 @@ const skipSetPwd = () => { showSetPwd.value = false }
|
|||||||
// ====== 微信登录 ======
|
// ====== 微信登录 ======
|
||||||
const doWxLogin = async () => {
|
const doWxLogin = async () => {
|
||||||
// #ifdef MP-WEIXIN
|
// #ifdef MP-WEIXIN
|
||||||
|
if (!checkAgreed()) { wxLoading.value = false; return }
|
||||||
wxLoading.value = true
|
wxLoading.value = true
|
||||||
try {
|
try {
|
||||||
const wxResp = await uni.login()
|
const wxResp = await uni.login()
|
||||||
@@ -354,8 +367,9 @@ const goPrivacy = () => uni.navigateTo({ url: '/pages/privacy/privacy' })
|
|||||||
.switch-hint { text-align: center; font-size: 22rpx; color: var(--color-primary); padding: 20rpx 0 4rpx; }
|
.switch-hint { text-align: center; font-size: 22rpx; color: var(--color-primary); padding: 20rpx 0 4rpx; }
|
||||||
|
|
||||||
/* ===== Legal ===== */
|
/* ===== Legal ===== */
|
||||||
.legal { display: flex; justify-content: center; align-items: center; gap: 4rpx; margin-top: 24rpx; flex-wrap: wrap; }
|
.legal { display: flex; justify-content: center; align-items: center; margin-top: 24rpx; flex-wrap: wrap; }
|
||||||
.legal-text { font-size: 22rpx; color: var(--color-text-tertiary); }
|
.agree-label { display: flex; align-items: center; gap: 6rpx; flex-wrap: wrap; justify-content: center; }
|
||||||
|
.agree-text { font-size: 22rpx; color: var(--color-text-tertiary); }
|
||||||
.legal-link { font-size: 22rpx; color: var(--color-primary); }
|
.legal-link { font-size: 22rpx; color: var(--color-primary); }
|
||||||
|
|
||||||
/* ===== Password Modal ===== */
|
/* ===== Password Modal ===== */
|
||||||
|
|||||||
@@ -107,8 +107,8 @@ const growthPriceText = ref('¥19.9')
|
|||||||
const sprintPriceText = ref('¥49.9')
|
const sprintPriceText = ref('¥49.9')
|
||||||
const currentOutTradeNo = ref('')
|
const currentOutTradeNo = ref('')
|
||||||
const freeFeatures = ref(['AI 模拟面试 1 次(体验)', '基础面试报告', '通用题库随机出题', '简历优化(限 3 次免费)'])
|
const freeFeatures = ref(['AI 模拟面试 1 次(体验)', '基础面试报告', '通用题库随机出题', '简历优化(限 3 次免费)'])
|
||||||
const growthFeatures = ref(['免费版全部权益', 'AI 数字人面试无限次', '详细面试报告(四维评分)', '进步轨迹雷达图 + 打卡', '每日一题推送', '参考回答思路', '公司真题库', '每场最多 10 轮 AI 对话'])
|
const growthFeatures = ref(['免费版全部权益', 'AI 数字人面试无限次', '详细面试报告(四维评分)', '进步轨迹雷达图 + 打卡', '每日一题', '参考回答思路', '公司真题库'])
|
||||||
const sprintFeatures = ref(['成长版全部权益', 'AI 语音分析(语气词/语速检测)', '技能缺口分析报告', '学习路径推荐', '真人导师 1v1 点评(每月 1 次)', '简历精修(每月 1 次)', '内推优先', 'AI 实时提示功能'])
|
const sprintFeatures = ref(['成长版全部权益', 'AI 语音分析(语气词/语速检测)', '技能缺口分析报告', '公司真题库精选'])
|
||||||
|
|
||||||
const token = () => uni.getStorageSync('token') || ''
|
const token = () => uni.getStorageSync('token') || ''
|
||||||
|
|
||||||
@@ -197,13 +197,17 @@ const startPay = async (selectedPlan) => {
|
|||||||
package: pp.package,
|
package: pp.package,
|
||||||
signType: pp.signType || 'RSA',
|
signType: pp.signType || 'RSA',
|
||||||
paySign: pp.paySign,
|
paySign: pp.paySign,
|
||||||
success: () => pollPayResult(res.data.prepayId, planLabel),
|
success: () => {
|
||||||
|
const no = currentOutTradeNo.value || res.data.outTradeNo
|
||||||
|
pollPayResult(no, planLabel)
|
||||||
|
},
|
||||||
fail: (err) => { payError.value = '支付取消或失败'; uni.showToast({ title: '支付取消', icon: 'none' }) },
|
fail: (err) => { payError.value = '支付取消或失败'; uni.showToast({ title: '支付取消', icon: 'none' }) },
|
||||||
})
|
})
|
||||||
} else {
|
} else {
|
||||||
payLoading.value = false
|
payLoading.value = false
|
||||||
payError.value = res.data?.message || '创建订单失败'
|
const errMsg = res.data?.message || '创建订单失败'
|
||||||
uni.showToast({ title: '创建订单失败', icon: 'none' })
|
payError.value = errMsg
|
||||||
|
uni.showToast({ title: errMsg, icon: 'none' })
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
payLoading.value = false
|
payLoading.value = false
|
||||||
|
|||||||
Reference in New Issue
Block a user