Compare commits

...

10 Commits

Author SHA1 Message Date
yuzhiran-dev 0f215d2aad P8 平台轻量化改造 + SSG修复 + 编程导师 + 文档完善
- Prisma: Tool 模型加 affiliateLink;免费用户沙盒 10→5 次/日
- 后端: Tools API + /admin/tools CRUD 5 端点;Practices 完整模块
- 导航: 主菜单隐藏企业版/社区(URL 可访问)
- 首页: 重定位为 AI 工具指南;新增精选工具区块;Feature 重写
- 工具页: affiliateLink 绿色推荐 Badge
- SSG 修复: config.ts 构建时直连 localhost:4000,页面 108→127
- 沙盒: 新增编程导师场景(苏格拉底教学法)
- 练习系统: Practices 多场景练习(含结构化评分)
- 技能广场: 6 个付费 Skill(标题大师/回款助手等)
- 管理后台: Models/Posts/Practices CRUD 页面
- 文档: README + progress.md 全面更新;AGENTS.md 同步定位
- 清理: .env.example 移除;tsbuildinfo gitignore
2026-06-18 18:14:07 +08:00
yuzhiran-dev fb8152401b 支付功能对齐网关文档 + 完善管理操作
- GatewayPayService: 新增 remark 字段(传uid/oid), 移除废弃 return_url, 新增 closeOrder/syncOrderStatus
- PaymentController: 新增 POST /payment/gateway/sync/:orderNo 和 /close/:id 端点
- Admin 订单页: 待支付订单增加「同步状态」按钮
- OrdersService: 下单时传入 remark 使 webhook 可匹配
- 清理 PaymentService 未使用依赖
- docs/progress.md 更新支付架构
2026-06-05 13:19:44 +08:00
yuzhiran-dev 538de50bb1 注册支持用户名/手机号/邮箱 + 镜像站部署脚本 + AI 助手 Tool Calling 重构
- Prisma User 模型新增 username 字段(唯一索引)
- 注册先查重复再创建,返回友好中文提示(非 500)
- 登录支持用户名/手机号/邮箱三种方式
- 前端注册表单增加用户名输入框,预校验 2-20 位格式
- 新增 scripts/deploy.sh:一键构建并部署主站+镜像站+重启后端+重载 Nginx
- 镜像站 www.yuzhiran.com.cn Nginx 配置与主站同步
- AI 助手架构升级:用户端/管理后台均采用完整 Tool Calling 架构
- 新增 UserAiAssistantService(18 工具)+ AiAssistantController
- admin 助手新增 search + mark-all-notifications-read 工具
- 修复注册 500 错误:catch Prisma P2002 → BadRequestException
- Baidu Analytics Script 注入 root layout
2026-06-01 23:14:42 +08:00
yuzhiran-dev 6f3fe50ee0 docs & cleanup: update stack info, remove dead code, fix backend bugs
- Fix AGENTS.md and 技术架构设计.md to reflect actual stack (Prisma+MySQL, static export, PM2)
- Remove 4 unused frontend components (page-transition, page-layout, image-upload, section-card)
- Fix card.tsx hardcoded colors → CSS variables
- Remove provider name from model selector, remove sensenova-u1-fast from models
- Fix analytics.controller.ts raw SQL table names (runtime bug)
- Add JWT auth guard to tools POST endpoint
- Fix AI gateway test: update env vars and model names
- Fix admin test: mock role structure for Prisma relation
2026-05-29 10:26:50 +08:00
yuzhiran-dev 417fb266d4 feat: AI沙箱流式对话+引导学习+静态服务优化
- 后端: AI沙箱流式对话(SSE), reasoning_content 回退支持
- 前端: 沙箱页流式渲染, 引导学习面板, 默认场景/Starter兜底
- 优化: 前端改用静态文件服务器(node server.js)替代next dev, CSS永不丢失
- 修复: 通用模型默认改为可用模型, predev不再删.next缓存
2026-05-27 18:26:30 +08:00
yuzhiran-dev 0b66d752ce feat: 系统配置页动态渲染 + AI 配置完善
- 配置页重写:从 API 动态加载配置项,不再依赖硬编码字段列表
- AI 配置扩展:新增 provider API Key/URL、配额、Token 等 11 项配置
- 站点配置扩展:联系电话、公司名称
- 支持新增自定义配置项(+ 新增配置按钮)
- 配置项显示已有 DB 数据 + 预定义字段的合并列表
2026-05-25 13:54:20 +08:00
yuzhiran-dev 25ebcdc622 fix: 沙盒页英文换行 + Logo 多语言
- 沙盒页标题/副标题添加 truncate 防止英文换行
- Logo 使用 t.brand.name/suffix 实现中英切换(宇之然/Yuzhiran)
- 新增 brand 翻译键(zh: 宇之然, en: Yuzhiran)
2026-05-25 11:07:40 +08:00
yuzhiran-dev e64a874499 fix: 导航 i18n 补全 + 模型/提示词页 useT()
- Header 导航 skills/models/articles 改为 t.nav.*(英文正常显示)
- 模型百科页全部文字使用 useT()(表格标题、标签等)
- 提示词库页标题使用 useT()
- models 翻译键补全 contextWindow/maxOutput
2026-05-25 09:22:25 +08:00
yuzhiran-dev 03b500b778 feat: 剩余页面 useT() + 页脚 SystemConfig 动态渲染
- 学习分析/学习路径/设置/收藏/提示词工坊 页面 useT()
- 页脚联系邮箱/ICP备案号从 GET /public/config 动态获取
- 翻译键补齐: discover.viewCount/likeCount/postStats
2026-05-22 18:09:15 +08:00
yuzhiran-dev 477226ac15 feat: 剩余页面 useT() 改造 + 首页课程动态加载
- 搜索/发现/工具/My 页面全部使用 useT(),支持中英切换
- 搜索页 Badge variant 修正
- 首页热门专题改为从 API 动态获取(无数据时用静态兜底)
- 新增 discover 翻译键 viewCount/likeCount/postStats
- 首页/仪表盘/社区 等 JSX 优化
2026-05-22 17:08:17 +08:00
360 changed files with 15157 additions and 2284 deletions
-97
View File
@@ -1,97 +0,0 @@
name: CI
on:
push:
branches: [main, develop]
pull_request:
branches: [main]
jobs:
backend-lint-and-test:
runs-on: ubuntu-latest
defaults:
run:
working-directory: ./backend
services:
mysql:
image: mysql:8.0
env:
MYSQL_ROOT_PASSWORD: root123
MYSQL_DATABASE: yuzhiran
ports:
- 3306:3306
options: --health-cmd="mysqladmin ping" --health-interval=10s --health-timeout=5s --health-retries=5
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
cache: 'npm'
cache-dependency-path: backend/package-lock.json
- name: Install dependencies
run: npm ci
- name: Generate Prisma client
run: npx prisma generate
env:
DATABASE_URL: mysql://root:root123@localhost:3306/yuzhiran
- name: Type check
run: npx tsc --noEmit
- name: Run tests
run: npm test
frontend-lint-and-test:
runs-on: ubuntu-latest
defaults:
run:
working-directory: ./frontend
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
cache: 'npm'
cache-dependency-path: frontend/package-lock.json
- name: Install dependencies
run: npm ci
- name: Run tests
run: npm test
- name: Build
run: npm run build
docker-build:
runs-on: ubuntu-latest
needs: [backend-lint-and-test, frontend-lint-and-test]
if: github.ref == 'refs/heads/main'
steps:
- uses: actions/checkout@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Build backend
uses: docker/build-push-action@v5
with:
context: ./backend
push: false
tags: yuzhiran/backend:latest
cache-from: type=gha
cache-to: type=gha,mode=max
- name: Build frontend
uses: docker/build-push-action@v5
with:
context: ./frontend
push: false
tags: yuzhiran/frontend:latest
cache-from: type=gha
cache-to: type=gha,mode=max
Regular → Executable
+6
View File
@@ -53,6 +53,12 @@ backend/cert/key/*.pem
backend/cert/key/*.p12
backend/cert/key/*.txt
# Build cache
*.tsbuildinfo
# AI agent workspace
.omo/
# Temp files
*.tmp
*.temp
Regular → Executable
+31 -16
View File
@@ -4,7 +4,7 @@
## 项目概览
宇之然 AI 学习与实践平台。前端 Next.js + shadcn/ui,后端 NestJS + PostgreSQL。
宇之然 AI 工具指南与技能练习平台。前端 Next.js + shadcn/ui,后端 NestJS + MySQL。
| 项目 | 值 |
|------|-----|
@@ -25,7 +25,7 @@
### 后端
- **框架**: NestJS 模块化架构
- **ORM**: TypeORM + PostgreSQLschema 在 `database/migrations/`
- **ORM**: Prisma + MySQLschema 在 `prisma/schema.prisma`
- **认证**: JWT + Passport
- **API 前缀**: `/api/v1`
- **CORS**: `origin: true`, `maxAge: 0`(避免浏览器预检缓存)
@@ -41,19 +41,28 @@
- hover → `hover:text-foreground` / `hover:bg-accent`
- **国际化**: 所有用户可见文本使用 `useT()` hook,翻译 key 位于 `src/i18n/locales/`。新增页面先加翻译 key 再写 UI。中文默认,英文同步维护。
## 关键架构决策
## 2026 市场验证数据(已修正)
| 决策 | 方案 | 原因 |
| 指标 | 数据 | 来源 |
|------|------|------|
| CORS | `origin: true` + `maxAge: 0` | 避免预检缓存, 开发灵活 |
| Favicon | `public/favicon.png` + `app/icon.svg` | `.ico``output: 'export'` 不兼容 |
| 静态导出 | `output: 'export'` | 部署到静态托管 |
| Sandbox 布局 | `h-[calc(100vh-4rem)] flex flex-col` | 固定头尾, 消息区滚动 |
| 流式聊天 | `ReadableStream` fetch API | 实时 AI 响应 |
| 法律页面 | `max-w-3xl` 窄容器 | 长文本可读性 |
| 模型列表 | `frontend/src/lib/models.ts` | 全站唯一数据源,3 个页面统一导入 |
| 颜色约定 | 全站禁用 `text-gray-*` / `bg-gray-*` / `border-gray-*` | 必须使用 CSS 变量 |
| 学情分析 | `GET /api/v1/learning/analytics` + `GET /api/v1/learning/path` | 基于对话知识度分析 |
| AI 教育市场 | $7-10B(2025) → $42B(2030)CAGR 40%+ | TBRC, Mordor Intelligence |
| Prompt 工程市场 | $505M(2025) → $673M(2026) → $6.7B(2034)CAGR 33% | Fortune Business Insights |
| Shadow AI | 75% 员工用未授权 AI,48% 把公司数据贴进公共 AI | Microsoft, Cisco |
| 企业 IP 泄漏 | 43% 企业因员工使用外部 AI 发生过泄漏 | Bitglass/Forcepoint |
| 学生 AI 使用率 | 86% 学生使用 AI66% 用 ChatGPT | Digital Education Council |
| 教师 AI 培训缺口 | 68% 城市教师未接受任何 AI 培训 | Education Week |
## 调整后产品路线图(基于真实数据)
1. **AI 工具指南(最高优先级)** ✅ — 收录优质 AI 工具,联盟返佣变现,首页展示精选工具
2. **练习系统** ✅ — Prompt 工程市场 $673M(2026),练习+评分是已验证的付费模式
3. **¥9.9 用量包** ✅ — 免费 5 次 → 付费 50 次,从沙盒使用量直接变现
4. **技能广场** ✅ — 一次性购买技能,¥9.9-29.9,独立变现渠道
5. ~~企业版~~ ↓ — 从主菜单隐藏,页面仍可通过 URL 访问,不主动运营
6. ~~社区/圈子~~ ↓ — 从主菜单隐藏,页面仍可访问,不主动运营
5. **内容扩充(下一个)** — 核心竞争围绕内容质量而非模型数量:增加练习场景、行业定制题、评分系统优化
## 关键架构决策
## 后端关键 API
@@ -72,7 +81,8 @@
| `GET /api/v1/learning/analytics` | 学情分析(需 JWT,返回知识领域掌握度) |
| `GET /api/v1/learning/path` | 学习路径进度(需 JWT,返回阶段任务完成情况) |
| `GET /api/v1/skills` | 技能列表(支持 ?category= / ?difficulty= / ?search= 过滤) |
| `GET /api/v1/skills/:id` | 技能详情(含 system prompt、练习任务、starter 问题) |
| `GET /api/v1/skills/:id` | 技能详情(含 system prompt、练习任务、starter 问题,返回 purchased/locked 状态 |
| `GET /api/v1/skills/marketplace` | 技能广场(返回所有技能含 price + purchased 状态,无需认证也可用) |
| `GET /api/v1/skills/categories` | 技能分类列表 |
| `GET /api/v1/skills/difficulties` | 难度等级列表 |
@@ -82,7 +92,12 @@
- `users.service.ts``if (status)` 曾误写为 `if (params.status)`(已修复)
- 静态构建 (`next build`) 已修复 —— 所有动态路由都通过 server wrapper 模式导出 `generateStaticParams()`60 页面全部生成)
- `public/favicon.ico``app/favicon.ico` 不能共存,会触发 500
- 数据库 schema 通过 TypeORM migration 管理, 位于 `backend/src/database/migrations/`
- ESLint 循环引用已在 `next.config.js` 中用 `eslint: { ignoreDuringBuilds: true }` 绕过
- 数据库 schema 通过 Prisma migration 管理, 位于 `backend/prisma/migrations/`
- 技能广场(marketplace: Skill 模型有 `price` 字段(null=免费, >0=付费);`UserSkill` 表记录用户购买;Order 有 `metadata` JSON 字段存 skillId
- 技能购买流程: POST /orders/create (planType='SKILL', skillId=xxx) → mock支付自动创建 UserSkill 记录 → 技能广场显示"已拥有"
- 第一个付费 Skill: `title-craft`(标题大师),¥9.9seed 在 `prisma/seed-marketplace.cjs`
- 后续 5 个付费 Skill: `payment-chaser`(回款助手 ¥19.9), `proposal-wizard`(提案智造 ¥19.9), `review-intel`(差评分析师 ¥19.9), `listing-optimizer`(商品文案大师 ¥29.9), `contract-reviewer`(合同审查助手 ¥29.9)
## 项目管理流程
@@ -90,7 +105,7 @@
1. **任务开始前** — 读 AGENTS.md + docs/progress.md,了解当前进度和上下文
2. **规划阶段** — 分析需求,拆解为 TODO 列表,按优先级排序
3. **执行阶段** — 按 TODO 依次实施,每次完成后验证(lint/test/build
3. **执行阶段** — 按 TODO 依次实施,完成后先做**代码评审修复**(检查代码风格、类型安全、边界情况、安全隐患、是否符合项目约定),再做**测试验证**(运行 lint/build + 相关测试,发现失败立即修复
4. **任务完成后** — 更新 docs/progress.md(更新阶段状态、补充完成项)
5. **归档** — 旧版 progress.md 移到 `docs/archive/progress-YYYY-MM-DD.md`
6. **提交** — 只有在用户明确要求时才创建 git commit
Regular → Executable
+83 -74
View File
@@ -1,6 +1,6 @@
# 宇之然 AI (YuZhiRan AI)
# 宇之然 AI 工具指南与技能练习平台
AI 学习与实践平台 — 让每个人都能用好 AI
AI 工具指南 · 实战练习 · 技能提升 — 让每个人都能用好 AI
| 信息 | 内容 |
|------|------|
@@ -14,85 +14,94 @@ AI 学习与实践平台 — 让每个人都能用好 AI
```
ai-learning-platform/
├── README.md # 项目说明
├── AGENTS.md # AI 项目知识库(开发助手专用)
├── docs/ # 项目文档
│ ├── progress.md # 进度追踪
│ ├── archive/ # 历史进度存档
│ ├── 产品规划书.md # 完整产品规划(含商业模式、可行性分析)
│ ├── 技术架构设计.md # 技术选型与系统架构
│ └── 合规与运营方案.md # ICP合规、数据合规、运营策略
├── frontend/ # 官网前端(Next.js + Tailwind
├── mobile/ # 移动端 App/小程序
├── frontend/ # 前端(Next.js 14 App Router + shadcn/ui
├── backend/ # 后端 APINestJS + Prisma + MySQL
│ ├── src/modules/payment/ # 微信支付模块(已完善)
│ ├── src/modules/ai/ # AI模型接入(支持OpenAI兼容接口
└── prisma/ # 数据模型定义
│ ├── src/modules/ # 功能模块
│ ├── payment/ # 支付模块(统一支付网关对接
│ ├── ai/ # AI 模型接入(OpenAI/Doubao/DeepSeek
│ │ ├── sandbox/ # AI 实操沙箱(多场景练习)
│ │ ├── skills/ # 技能系统(付费 Skill 商店)
│ │ ├── tools/ # AI 工具指南
│ │ ├── practices/ # 练习系统
│ │ └── marketplace/ # 技能广场 API
│ ├── prisma/ # 数据模型定义(含 seed 脚本)
│ └── cert/ # 支付证书
└── scripts/ # 工具脚本
└── setup.sh # 项目初始化脚本
└── deploy.sh # 部署脚本
```
## 技术栈
### 后端
- **框架**: NestJS (Node.js)
- **ORM**: Prisma
- **数据库**: MySQL
- **支付**: 微信支付 V3 SDK
- **AI接口**: OpenAI兼容接口、DeepSeek、通义千问
### 前端
- **框架**: Next.js 14
- **样式**: Tailwind CSS
- **状态管理**: React Hooks
## 快速开始
### 1. 初始化项目
```bash
cd ai-learning-platform
./scripts/setup.sh
```
### 2. 手动配置
#### 后端配置
编辑 `backend/.env` 文件,配置:
- 微信支付参数(已从 `/www/wwwroot/sharefile/note.txt` 获取)
- AI模型API密钥
- 数据库密码
#### 前端配置
编辑 `frontend/.env.local` 文件,配置API地址。
### 3. 启动开发环境
```bash
# 启动后端(默认端口3000
cd backend && npm run dev
# 启动前端(默认端口3001
cd frontend && npm run dev
```
### 4. 访问
- 前端: http://localhost:3001
- 后端API文档: http://localhost:3000/api
## 核心功能
- ✅ 用户系统(注册、登录、会员管理)
- ✅ 微信支付集成(JSAPI/NATIVE/MWEB
- ✅ 会员订阅管理(月卡/年卡自动续期)
- ✅ AI模型接入(支持美团longcat等OpenAI兼容接口)
- ✅ 课程系统
- ✅ 提示词库
- ✅ AI实操沙箱
- ✅ 社区功能
## 产品定位
面向大众化分领域用户的 AI 学习与实践平台,涵盖
- AI 基础知识与通识教育
- 提示词工程教学与模板库
- 智能体(Agent)使用教程
- 大模型介绍与选型指南
- AI 实操沙箱体验
面向大众**AI 工具指南与技能练习平台**,帮助用户
- 发现优质 **AI 工具**(含返佣推荐)
- 系统学习 **Prompt 工程**(体系化课程)
- **AI 实操沙箱** 中练习提示词技能
- 购买一次性付费 **AI 技能**(垂直场景解决方案)
- 通过 **编程导师** 苏格拉底式学习编程
## 技术栈
### 前端
- **框架**: Next.js 14 (App Router, `output: 'export'` 静态导出)
- **UI**: shadcn/ui + Tailwind CSS(暗黑模式,CSS 变量主题)
- **国际化**: next-intl(中/英文,中文默认)
- **状态管理**: React Server Components 优先,客户端 `useState`/`useEffect`
### 后端
- **框架**: NestJS (Node.js)
- **ORM**: Prisma + MySQL
- **认证**: JWT + Passport
- **AI 接口**: OpenAI 兼容接口(DeepSeek、字节豆包、通义千问)
- **支付**: 统一支付网关(yzrcloud.cn,支持支付宝/微信)
## 快速开始
```bash
# 后端开发(端口 4000
cd backend
cp .env.example .env # 配置数据库密码、AI 密钥等
npm install
npx prisma generate
npx prisma db push # 初始化数据库
npm run start:dev
# 前端开发(端口 3000
cd frontend
cp .env.local.example .env.local # 或直接设置 NEXT_PUBLIC_API_URL=/api/v1
npm install
npm run dev
```
**注意**: 前端为 SSG`output: 'export'`),构建时需要后端运行在 `localhost:4000` 以确保 `generateStaticParams` 能获取动态路由参数。
## 核心功能
| 功能 | 说明 | 状态 |
|------|------|------|
| 🛠️ **AI 工具指南** | 收录优质 AI 工具,含返佣链接推荐 | ✅ 已上线 |
| 📚 **课程系统** | AI 系统课程(免费/付费),含章节和课时 | ✅ 已上线 |
| 🧪 **AI 实操沙箱** | 多场景提示词练习(写作/编程/客服/翻译/编程导师) | ✅ 已上线 |
| 💰 **技能广场** | 一次性付费购买垂直场景 AI 技能(¥9.9-¥29.9) | ✅ 已上线 |
| 👤 **用户系统** | 注册/登录/会员管理 | ✅ 已上线 |
| 🧑‍🏫 **编程导师** | 苏格拉底式教学,不直接给答案 | ✅ 已上线 |
| 📊 **学情分析** | 知识领域掌握度跟踪 | ✅ 已上线 |
| 🏢 **企业版** | 企业 AI 培训管理(隐藏菜单,URL 可访问) | ⏸️ 暂停运营 |
## 部署架构
```
用户 → Nginx (443/80) → /api/* → backend (127.0.0.1:4000, PM2)
→ /* → static files (/www/wwwroot/www.yuzhiran.com/)
```
- 前端: Next.js SSG 导出到 `/www/wwwroot/www.yuzhiran.com/`Nginx 直接提供
- 后端: NestJS + PM2 常驻 4000 端口
- 数据库: MySQL 8.0 (systemd)
- Web 服务器: Nginx(宝塔面板管理)
Regular → Executable
View File
-32
View File
@@ -1,32 +0,0 @@
# Database
DATABASE_URL="mysql://yuzhiran:yuzhiran123@localhost:3306/yuzhiran"
# JWT
JWT_SECRET=yuzhiran-jwt-secret-2024
JWT_EXPIRES_IN=2h
# Server
PORT=4000
# AI Models (OpenAI-compatible)
OPENAI_API_KEY=
OPENAI_BASE_URL=https://api.openai.com/v1
AI_DEFAULT_MODEL=gpt-3.5-turbo
# DeepSeek
DEEPSEEK_API_KEY=
DEEPSEEK_BASE_URL=https://api.deepseek.com
# DashScope (通义千问)
DASHSCOPE_API_KEY=
DASHSCOPE_BASE_URL=https://dashscope.aliyuncs.com/compatible-mode/v1
# WeChat Pay
WX_APPID=
WX_MCHID=
WX_KEY=
WX_CERT_PATH=
WX_CERT_KEY_PATH=
# WeChat Mini Program
WX_SECRET=
+23
View File
@@ -0,0 +1,23 @@
module.exports = {
parser: '@typescript-eslint/parser',
parserOptions: {
project: './tsconfig.json',
sourceType: 'module',
},
plugins: ['@typescript-eslint'],
extends: [
'eslint:recommended',
'plugin:@typescript-eslint/recommended',
],
root: true,
env: {
node: true,
jest: true,
},
ignorePatterns: ['dist', 'node_modules'],
rules: {
'@typescript-eslint/no-unused-vars': ['warn', { argsIgnorePattern: '^_' }],
'@typescript-eslint/no-explicit-any': 'warn',
'no-console': 'off',
},
};
Regular → Executable
View File
Regular → Executable
View File
Regular → Executable
View File
Regular → Executable
View File
Regular → Executable
View File
Generated Regular → Executable
+1583 -103
View File
File diff suppressed because it is too large Load Diff
Regular → Executable
+9 -1
View File
@@ -4,7 +4,7 @@
"description": "宇之然 AI - 后端 API 服务",
"main": "dist/main.js",
"scripts": {
"build": "nest build",
"build": "nest build --tsc",
"start": "nest start",
"dev": "nest start --watch",
"start:prod": "node dist/main",
@@ -37,12 +37,16 @@
"@nestjs/platform-express": "^11.1.19",
"@nestjs/serve-static": "^5.0.5",
"@nestjs/swagger": "^11.4.2",
"@nestjs/throttler": "^6.5.0",
"@prisma/client": "^5.22.0",
"alipay-sdk": "^4.14.0",
"bcryptjs": "^3.0.3",
"class-transformer": "^0.5.1",
"class-validator": "^0.15.1",
"compression": "^1.8.1",
"cookie-parser": "^1.4.7",
"helmet": "^8.1.0",
"ioredis": "^5.11.1",
"passport": "^0.7.0",
"passport-jwt": "^4.0.1",
"prisma": "^5.22.0",
@@ -56,12 +60,16 @@
"@nestjs/testing": "^11.1.19",
"@types/bcryptjs": "^2.4.6",
"@types/compression": "^1.8.1",
"@types/cookie-parser": "^1.4.10",
"@types/express": "^5.0.6",
"@types/jest": "^30.0.0",
"@types/multer": "^2.1.0",
"@types/node": "^20.19.40",
"@types/passport-jwt": "^4.0.1",
"@types/supertest": "^7.2.0",
"@typescript-eslint/eslint-plugin": "^8.60.0",
"@typescript-eslint/parser": "^8.60.0",
"eslint": "^8.57.1",
"jest": "^30.4.0",
"supertest": "^7.2.2",
"ts-jest": "^29.4.9",
Regular → Executable
+88 -15
View File
@@ -34,6 +34,7 @@ enum MemberPlan {
model User {
id Int @id @default(autoincrement())
username String? @unique
phone String? @unique
email String? @unique
passwordHash String?
@@ -43,7 +44,8 @@ model User {
status UserStatus @default(ACTIVE)
memberPlan MemberPlan @default(FREE)
memberExpire DateTime?
sandboxDaily Int @default(10)
sandboxDaily Int @default(5)
sandboxExtra Int @default(0)
followerCount Int @default(0)
followingCount Int @default(0)
postCount Int @default(0)
@@ -67,6 +69,8 @@ model User {
circleMemberships CircleMember[]
organizationMemberships OrganizationMember[]
notifications Notification[]
practiceSubmissions PracticeSubmission[]
userSkills UserSkill[]
@@map("users")
}
@@ -131,7 +135,7 @@ model Lesson {
id Int @id @default(autoincrement())
chapterId Int
title String
content String?
content String? @db.Text
videoUrl String?
duration Int?
sortOrder Int @default(0)
@@ -206,6 +210,7 @@ model Tool {
description String?
url String
icon String?
affiliateLink String?
categoryId Int?
tags String?
isFeatured Boolean @default(false)
@@ -224,7 +229,7 @@ model Content {
id Int @id @default(autoincrement())
title String
summary String?
content String?
content String? @db.Text
cover String?
categoryId Int?
tags String?
@@ -275,11 +280,14 @@ model Skill {
tasks String @db.Text
tags String
prerequisiteIds String?
price Float? @default(0)
sortOrder Int @default(0)
status String @default("ACTIVE")
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
purchasedBy UserSkill[]
@@map("skills")
}
@@ -303,24 +311,43 @@ model SandboxSession {
}
model Order {
id Int @id @default(autoincrement())
orderNo String @unique
userId Int
amount Float
planType String
status OrderStatus @default(PENDING)
payChannel String?
paidAt DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
id Int @id @default(autoincrement())
orderNo String @unique
userId Int
amount Float
planType String
status OrderStatus @default(PENDING)
payChannel String?
transactionId String?
gatewayOrderId String?
metadata String?
paidAt DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
user User @relation(fields: [userId], references: [id])
user User @relation(fields: [userId], references: [id])
userSkills UserSkill[]
@@index([userId, status])
@@index([status, createdAt])
@@map("orders")
}
model UserSkill {
id Int @id @default(autoincrement())
userId Int
skillId String
orderId Int?
purchasedAt DateTime @default(now())
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
skill Skill @relation(fields: [skillId], references: [id])
order Order? @relation(fields: [orderId], references: [id])
@@unique([userId, skillId])
@@map("user_skills")
}
model Subscription {
id Int @id @default(autoincrement())
userId Int
@@ -381,7 +408,7 @@ model Post {
id Int @id @default(autoincrement())
userId Int
title String
content String
content String @db.Text
tags String?
circleId Int?
status String @default("PUBLISHED")
@@ -544,6 +571,52 @@ model Notification {
@@map("notifications")
}
model PracticeQuestion {
id Int @id @default(autoincrement())
skillId String?
scenario String @db.Text
title String
description String @db.Text
instructions String @db.Text
difficulty String @default("BEGINNER")
category String @default("general")
expectedCriteria String @db.Text
starterCode String? @db.Text
hint String? @db.Text
sortOrder Int @default(0)
status String @default("PUBLISHED")
attemptCount Int @default(0)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
submissions PracticeSubmission[]
@@map("practice_questions")
}
model PracticeSubmission {
id Int @id @default(autoincrement())
userId Int
questionId Int
answer String @db.Text
score Int?
maxScore Int @default(100)
feedback String? @db.Text
criteriaScores String? @db.Text
duration Int @default(0)
status String @default("SUBMITTED")
submittedAt DateTime @default(now())
scoredAt DateTime?
question PracticeQuestion @relation(fields: [questionId], references: [id], onDelete: Cascade)
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@index([userId])
@@index([questionId])
@@index([userId, status])
@@map("practice_submissions")
}
model Banner {
id Int @id @default(autoincrement())
title String
+395
View File
@@ -0,0 +1,395 @@
import { PrismaClient } from '@prisma/client';
const prisma = new PrismaClient();
async function main() {
console.log('Enriching seed data...\n');
// ===== 1. AI Models (12 models with real 2026 data) =====
console.log('Seeding AI models...');
const models = [
{ name: 'GPT-5.4', provider: 'OpenAI', description: 'OpenAI 最新旗舰模型,拥有强大的推理能力和多模态理解能力。在 coding benchmark LiveCodeBench 上得分 87.0%,支持 272K 上下文窗口。适合通用对话、编程、分析等多种任务。', capabilities: '文本生成、代码编写、图像理解、语音对话', contextWindow: 272000, maxTokens: 32768, pricing: 'API: $1.25/1M input, $10/1M output; Plus $20/月', isFeatured: true, sortOrder: 1 },
{ name: 'GPT-5.4 Pro', provider: 'OpenAI', description: 'GPT-5.4 增强版,专门针对高精度推理场景优化。AIME 2025 数学竞赛得分 94%GPQA Diamond 得分约 88%。适合需要高准确率的复杂分析场景。', capabilities: '深度推理、数学竞赛、复杂分析、学术研究', contextWindow: 400000, maxTokens: 65536, pricing: 'Pro $200/月; API 按用量计费', isFeatured: true, sortOrder: 2 },
{ name: 'Claude Opus 4.6', provider: 'Anthropic', description: 'Anthropic 最强模型,在编程、长文档分析和深度推理方面表现领先。拥有 1M token 超长上下文窗口,编码能力领先行业。适合企业级复杂任务。', capabilities: '编码、长文档分析、深度推理、JSON 输出', contextWindow: 1000000, maxTokens: 65536, pricing: 'API: $5/1M input, $15/1M output; Pro $20/月; Max $200/月', isFeatured: true, sortOrder: 3 },
{ name: 'Claude Sonnet 4.6', provider: 'Anthropic', description: 'Claude 系列的均衡型号,在速度和质量的平衡上表现优异。支持 Agentic 工作流和 Computer Use 能力。适合生产环境部署。', capabilities: 'Agent 工作流、Computer Use、均衡推理', contextWindow: 500000, maxTokens: 32768, pricing: 'API: $3/1M input, $10/1M output', sortOrder: 4 },
{ name: 'Gemini 3.1 Pro', provider: 'Google', description: 'Google 最新旗舰模型,推理能力行业领先。在 GPQA 基准上得分 94.3%,输出速度达 114.8 tokens/s。支持长上下文和原生多模态。2026 年 3 月发布。', capabilities: '推理、多模态、长上下文、高速输出', contextWindow: 1000000, maxTokens: 65536, pricing: 'API: $2/1M input, $8/1M output; Advanced $19.99/月', isFeatured: true, sortOrder: 5 },
{ name: 'Gemini 2.5 Flash', provider: 'Google', description: 'Google 高性价比模型,价格仅 $0.10/1M tokens,但性能仍然强劲。适合高频调用场景,性价比业界最佳。', capabilities: '高速推理、低成本、批量处理', contextWindow: 200000, maxTokens: 16384, pricing: 'API: $0.10/1M input, $0.40/1M output', sortOrder: 6 },
{ name: 'DeepSeek V4', provider: 'DeepSeek', description: '深度求索最新开源自研模型,以极低API价格震撼市场。性能接近 GPT-5,但价格仅为其约 1/50。支持中文优化,开源可私有化部署。', capabilities: '开源、中文优化、低成本、私有化部署', contextWindow: 128000, maxTokens: 16384, pricing: 'API: $0.03/1M input, $0.10/1M output; 开源免费', isFeatured: true, sortOrder: 7 },
{ name: 'Llama 4', provider: 'Meta', description: 'Meta 开源大模型最新版本,支持本地部署和微调。社区生态活跃,拥有大量工具链和优化方案。适合数据敏感的企业场景。', capabilities: '开源、本地部署、可微调、社区生态', contextWindow: 128000, maxTokens: 16384, pricing: '开源免费,可本地部署', sortOrder: 8 },
{ name: 'Qwen 3.5 (通义千问)', provider: 'Alibaba Cloud', description: '阿里云最新版通义千问模型,中文理解和生成能力领先。深度整合阿里云生态,支持企业级部署。', capabilities: '中文优化、电商/办公场景、阿里云生态', contextWindow: 128000, maxTokens: 32768, pricing: 'API: ¥1/1M input; 企业版按量计费', sortOrder: 9 },
{ name: 'Grok 4.1', provider: 'xAI', description: 'xAI 开发的最新模型,以实时网络信息和幽默风格著称。在实时数据分析和社交媒体场景表现突出。', capabilities: '实时信息、社交媒体分析、代码生成', contextWindow: 256000, maxTokens: 16384, pricing: 'Premium+ $40/月', sortOrder: 10 },
{ name: 'GLM-5 (智谱清言)', provider: '智谱AI', description: '智谱AI最新自研模型,在中文理解和逻辑推理方面表现优异。支持多种任务类型,适合企业级应用。', capabilities: '中文理解、逻辑推理、代码生成', contextWindow: 128000, maxTokens: 16384, pricing: 'API: ¥2/1M input', sortOrder: 11 },
{ name: 'ERNIE 5.0 (文心一言)', provider: 'Baidu', description: '百度最新版文心大模型,深度整合百度搜索和知识图谱。在中文知识问答和内容生成方面有独特优势。', capabilities: '中文知识、搜索整合、多模态生成', contextWindow: 128000, maxTokens: 16384, pricing: 'API: ¥1.5/1M input; 专业版 $59.9/月', sortOrder: 12 },
];
for (const m of models) {
await prisma.aiModel.upsert({
where: { id: m.sortOrder },
update: { name: m.name, provider: m.provider },
create: m,
});
}
console.log(`${models.length} AI models`);
// ===== 1.5 Ensure categories exist =====
const defaultCatIds: Record<string, number> = {};
const neededCategories = [
{ name: 'AI 入门', slug: 'ai-basics', description: 'AI 基础知识与入门指南' },
{ name: '办公效率', slug: 'office-productivity', description: '用 AI 提升办公效率' },
{ name: '创意设计', slug: 'creative-design', description: 'AI 辅助创意与设计' },
{ name: '编程开发', slug: 'programming', description: 'AI 辅助编程开发' },
{ name: '教育学习', slug: 'education', description: 'AI 在教育中的应用' },
{ name: '提示词技巧', slug: 'prompt-engineering', description: '提示词工程与技巧' },
{ name: 'AI 工具', slug: 'ai-tools', description: 'AI 工具收录与评测' },
{ name: '模型百科', slug: 'model-encyclopedia', description: '大模型介绍与对比' },
];
for (const cat of neededCategories) {
const c = await prisma.category.upsert({
where: { slug: cat.slug },
update: {},
create: { name: cat.name, slug: cat.slug, description: cat.description },
});
defaultCatIds[cat.slug] = c.id;
}
// ===== 2. Content / Articles (12 articles) =====
console.log('Seeding articles...');
const articles = [
{
title: '2026 AI 教育市场报告:88% 学生已在使用 AI,但 80% 大学仍无规范',
summary: '根据 Digital Education Council 与 Stanford HAI 2026 报告,AI 在教育领域的使用率已达 88%,但 AI 素养培训严重滞后。本文深度解读最新数据及对教育者的启示。',
content: `## 核心发现\n\n2026 年是 AI 在教育领域从"实验"走向"标配"的转折点。根据多项权威调查:\n\n- **88% 的学生**已在学习中使用生成式 AI 工具(Digital Education Council 2026\n- **60% 的教师**每周使用 AI 辅助备课和批改作业(Gallup-Walton 2026\n- 使用 AI 的教师**平均每周节省 5.9 小时**,相当于每学年省出 6 周时间\n- 全球 AI 教育市场规模在 2025 年达到 **$75.7 亿美元**,同比增长 38.4%\n- 预计到 2034 年将达到 **$1123 亿美元**\n\n## 最大的问题\n\n尽管使用率极高,但仅 **7% 的学校**制定了正式的 AI 使用指导方针(UNESCO 2026 调查)。这导致:\n- 学生不知道哪些 AI 使用方式属于学术不端\n- 教师缺乏评估 AI 辅助作业的标准\n- 学校面临 AI 作弊检测的军备竞赛\n\n## 对教育者的建议\n\n1. **制定 AI 使用政策**:明确哪些场景允许/不允许使用 AI\n2. **开设 AI 素养课**:72% 的学生希望大学提供 AI 培训\n3. **重新设计评估方式**:从"考核记忆"转向"考核 AI 协作能力"\n4. **教师先行**:先培训教师,再指导学生`,
contentType: 'news',
categoryId: defaultCatIds['education'],
tags: 'AI教育,学生,大学,报告,数据',
authorName: '宇之然研究院',
isAiGenerated: false,
publishedAt: new Date('2026-05-15'),
viewCount: 2847,
},
{
title: 'Prompt 工程入门:从零开始写出高质量提示词',
summary: '提示词工程(Prompt Engineering)是 2026 年最热门的 AI 技能之一。本文从 COSTAR 框架开始,带你系统掌握写提示词的方法论。',
content: `## 为什么 Prompt 工程如此重要\n\n同样的 AI 模型,不同的提示词效果天差地别。2026 年 Prompt 工程市场规模已达 $6.73 亿,预计 2034 年将增长至 $67 亿。\n\n## COSTAR 框架\n\n这是 2026 年最主流的提示词框架:\n\n- **C**ontext(上下文):告诉 AI 它的角色和背景\n- **O**bjective(目标):明确你要它完成什么任务\n- **S**tyle(风格):指定输出风格(专业/通俗/幽默)\n- **T**one(语气):设定语气(正式/友好/紧迫)\n- **A**udience(受众):说明内容面向谁\n- **R**esponse(输出格式):指定格式(JSON/表格/段落)\n\n## 实战示例\n\n**不好的 Prompt**\n> 帮我写一封邮件\n\n**好的 Prompt**\n> 你是一位[客户成功经理],需要给[一位刚续费 1 年的企业客户]写一封感谢邮件。语气[专业且温暖],重点突出[他们选择我们之后的业务增长数据],最后[附上一个新功能的内测邀请]。格式:简短段落,不超过 200 字。\n\n## 进阶技巧\n\n1. **Chain-of-Thought**:让 AI 逐步思考再给答案\n2. **Few-Shot**:在提示词中给出 2-3 个示例\n3. **角色扮演**:给 AI 设定一个专业角色\n4. **约束条件**:明确什么不能做`,
contentType: 'tutorial',
categoryId: defaultCatIds['prompt-engineering'],
tags: 'Prompt工程,提示词,教程,入门',
authorName: '宇之然AI学院',
isAiGenerated: false,
publishedAt: new Date('2026-05-20'),
viewCount: 5632,
},
{
title: 'GPT-5.4 vs Claude 4.6 vs Gemini 3.12026 年大模型横评',
summary: '2026 年三大 AI 巨头全部更新了旗舰模型。本文从推理、编码、成本、速度四个维度进行全面的横向对比,帮你选择最适合的模型。',
content: `## 三强格局\n\n2026 年 3 月,OpenAI、Anthropic、Google 几乎同时发布了旗舰更新,市场上首次出现三款实力接近的顶级模型。\n\n## 推理能力对比\n\n| 维度 | GPT-5.4 Pro | Claude Opus 4.6 | Gemini 3.1 Pro |\n|------|------------|----------------|----------------|\n| AIME 2025 (数学) | **94%** | 92% | 91% |\n| GPQA Diamond (科学) | ~88% | ~86% | **94.3%** |\n| LiveCodeBench (编码) | 87.0% | **89.5%** | 85.2% |\n| MMLU (通用知识) | 91.5% | 90.8% | **93.1%** |\n\n## 速度对比\n\n| 模型 | 输出速度 |\n|------|---------|\n| GPT-5.4 | 82.1 tokens/s |\n| Claude Opus 4.6 | 72.3 tokens/s |\n| Gemini 3.1 Pro | **114.8 tokens/s** |\n\n## 价格对比\n\n| 模型 | 输入 (per 1M tokens) | 输出 (per 1M tokens) |\n|------|-------------------|--------------------|\n| GPT-5.4 | $1.25 | $10 |\n| Claude Opus 4.6 | $5 | $15 |\n| Gemini 3.1 Pro | $2 | $8 |\n| DeepSeek V4 | **$0.03** | **$0.10** |\n\n## 结论\n\n- **编程首选**Claude Opus 4.6\n- **科学推理首选**Gemini 3.1 Pro\n- **综合通用首选**GPT-5.4\n- **性价比首选**DeepSeek V4(价格仅为 GPT 的 1/50`,
contentType: 'article',
categoryId: defaultCatIds['ai-basics'],
tags: 'GPT,Claude,Gemini,模型对比,评测',
authorName: '宇之然研究院',
isAiGenerated: true,
publishedAt: new Date('2026-06-01'),
viewCount: 12458,
},
{
title: '企业 Shadow AI 治理:75% 员工在用未授权 AI,你该怎么办?',
summary: 'Microsoft 2026 报告显示 75% 的员工使用未授权的 AI 工具处理工作,48% 将公司数据粘贴到公共 AI 平台。本文提供落地治理方案。',
content: `## Shadow AI 有多严重?\n\n2026 年的数据令人震惊:\n\n- **75% 的员工**在工作中使用未授权的 AI 工具(Microsoft 2026\n- **48% 的员工**将公司数据粘贴到公共 AI 平台(Cisco 2026\n- **43% 的企业**已因员工使用外部 AI 发生过数据泄漏(Bitglass 2026\n- 平均每家企业有 **12+ 个未经批准的 AI 工具**被员工使用\n\n## 三步治理方案\n\n### 第一步:发现\n- 部署 DLP(数据防泄漏)监控出站 API 流量\n- 在网络网关层识别已知 AI 平台域名\n- 浏览器扩展检测(如 Forcepoint 的 Shadow AI 检测)\n\n### 第二步:管控\n- 禁止向未经批准的 AI 平台上传敏感数据\n- 建立"AI 白名单"制度\n- 为敏感部门部署私有化 AI\n\n### 第三步:赋能\n- 提供安全的企业级 AI 工具(数据不出域)\n- 制定 AI 使用指南,明确什么数据可以/不可以输入 AI\n- 定期 AI 安全培训`,
contentType: 'article',
tags: 'Shadow AI,企业安全,数据治理,合规',
authorName: '宇之然安全团队',
isAiGenerated: false,
publishedAt: new Date('2026-05-28'),
viewCount: 3891,
},
{
title: '2026 年最值得关注的 10 个 AI 工具',
summary: '从 ChatGPT(8.45亿月活)到 Cursor(增长最快的 AI IDE),本文盘点 2026 年上半年最值得关注的 10 个 AI 工具。',
content: `## 1. ChatGPT (OpenAI)\n- 月活用户:**8.45 亿**\n- 日处理提示词:**25 亿**\n- 2026 年新增:GPT-5.4 集成、Computer Use API\n\n## 2. Claude (Anthropic)\n- 月访问量:600 万\n- 核心优势:1M 上下文、编码能力领先\n- 2026 年新增:Claude Code CLI、Computer Use\n\n## 3. Gemini (Google)\n- 月活用户:6080 万\n- 核心优势:原生多模态、Google 生态整合\n- 2026 年新增:Gemini 3.1 Pro、Veo 视频生成\n\n## 4. GitHub Copilot\n- 行业地位:最广泛使用的 AI 编程助手\n- 已支持 VS Code、JetBrains、Neovim\n\n## 5. Cursor\n- 增长最快的 AI 代码编辑器\n- 熟悉 VS Code 的用户可零成本迁移\n\n## 6. Perplexity AI\n- 月访问量:1060 万\n- 定位:AI 搜索引擎\n\n## 7. Midjourney\n- 月活用户:6080 万\n- AI 图像生成领域的标杆\n\n## 8. Canva\n- 月活用户:2.86 亿\n- AI 设计工具的普及者\n\n## 9. Notion AI\n- AI 增强的知识管理与写作\n\n## 10. DeepSeek\n- 开源模型的价格屠夫\n- API 价格仅为 GPT 的 1/50`,
contentType: 'article',
categoryId: defaultCatIds['ai-basics'],
tags: 'AI工具,2026,推荐,盘点',
authorName: '宇之然编辑部',
isAiGenerated: false,
publishedAt: new Date('2026-06-05'),
viewCount: 7231,
},
{
title: 'AI 编程助手实战:用 Cursor 和 Copilot 提升 3 倍效率',
summary: '2026 年 AI 编程已从"玩具"变成"刚需"。Devpost 报告显示 1/4 的开发者提交已是 Agentic 项目。本文带你实战配置 AI 编程工作流。',
content: `## AI 编程的 2026 年现状\n\n- **78% 的企业**已在至少一个业务线使用 AI(McKinsey 2026\n- Devpost 2026 报告显示:**1/4 的社区项目提交**已是 Agentic AI 项目\n- 参与 6 次以上黑客松的开发者,将 AI 项目部署到生产的概率是初学者的 **2.2 倍**\n\n## Cursor + Copilot 双配置\n\n### 1. Cursor 基础设置\n\nCursor 是目前最受欢迎的 AI 原生 IDE,支持 Composer(多文件编辑)和 Chat(对话式编程):\n\n- **Tab 补全**:AI 预测你的下一步操作\n- **Cmd+K**:选中代码后直接用自然语言修改\n- **Composer**Ctrl+I):多文件协同编辑\n\n### 2. GitHub Copilot 高级玩法\n\n- **Agent Mode**AI 自主执行多步骤开发任务\n- **代码审查**AI 自动审查 PR\n- **测试生成**:一句命令生成完整测试套件`,
contentType: 'tutorial',
categoryId: defaultCatIds['programming'],
tags: '编程,Cursor,Copilot,效率,AI编程',
authorName: '宇之然技术团队',
isAiGenerated: false,
publishedAt: new Date('2026-06-03'),
viewCount: 4562,
},
{
title: '70% 企业已在用 AI Agent,你的团队准备好了吗?',
summary: 'Gartner 预测 40% 的企业应用将在 2026 年嵌入 AI Agent。PwC 调查显示 79% 的企业已采用 AI Agent。这篇文章帮你理解 AI Agent 浪潮。',
content: `## AI Agent 的 2026 年\n\n- **40% 的企业应用**将在 2026 年包含 AI AgentGartner\n- **79% 的公司**已在组织内使用 AI AgentPwC 2026\n- **88% 的高管**计划因 Agentic AI 增加 AI 预算\n- **66% 的公司**表示 AI Agent 带来了可量化的生产力提升\n- **15% 的日常业务决策**将在 2026 年由 AI Agent 自主完成\n\n## AI Agent 能做什么?\n\n1. **客服 Agent**:自动处理 80% 的常见咨询\n2. **销售 Agent**:线索筛选、初步沟通、会议安排\n3. **运营 Agent**:自动监控、告警、修复\n4. **开发 Agent**:自主编码、测试、部署\n\n## 如何落地\n\n1. 从**低风险场景**开始(内部工具、自动化报表)\n2. 建立**人机协作**流程(Agent 执行 + 人工审批)\n3. 持续监控 Agent 的**决策质量**和**异常行为**`,
contentType: 'news',
tags: 'AI Agent,企业,趋势,Gartner',
authorName: '宇之然研究院',
isAiGenerated: true,
publishedAt: new Date('2026-05-25'),
viewCount: 3247,
},
{
title: 'AI 面试官来了:HR 如何应对 AI 招聘变革',
summary: '69% 的企业已在招聘中使用 AI,但 43% 的 AI 招聘系统存在偏见风险。本文从 HR 视角分析 AI 招聘的机遇与挑战。',
content: `## AI 招聘的现状\n\n- **69% 的公司**已在招聘流程中使用 AI(2026 年 SHRM 调查)\n- AI 简历筛选可**减少 75% 的初筛时间**\n- 但 **43% 的 AI 招聘系统**被发现存在种族或性别偏见\n\n## 主要应用场景\n\n1. **简历初筛**:自动匹配技能要求\n2. **面试评估**:AI 分析面试回答\n3. **候选人匹配**:智能推荐最适合的岗位\n\n## 避免偏见的黄金法则\n\n1. **审查训练数据**的性别/种族分布\n2. 使用**公平性指标**Demographic Parity < 0.1\n3. 保持**人工审核**环节\n4. 定期进行**偏见审计**`,
contentType: 'article',
tags: 'HR,招聘,AI面试,偏见审计',
authorName: '宇之然HR科技团队',
isAiGenerated: false,
publishedAt: new Date('2026-05-18'),
viewCount: 2158,
},
{
title: '从 ChatGPT 到 DeepSeek2026 年 AI 模型格局全览',
summary: '2026 年 AI 模型市场从三足鼎立变为群雄逐鹿。22 个前沿模型、5 大阵营、开源 vs 闭源格局全面分析。',
content: `## 2026 年格局变化\n\n2023 年只有 ChatGPT 一个玩家,2024 年出现三足鼎立(GPT/Claude/Gemini),2026 年已经扩展到 **22 个前沿模型**、**5 大模型阵营**:\n\n1. **OpenAI 阵营**GPT-5.4 / GPT-5 Mini / GPT-5 Nano\n2. **Anthropic 阵营**Claude Opus / Sonnet / Haiku\n3. **Google 阵营**Gemini Pro / Flash / Flash-Lite\n4. **中国阵营**DeepSeek V4 / Qwen 3.5 / GLM-5 / ERNIE 5.0\n5. **开源阵营**Llama 4 / Mistral / Grok\n\n## 关键趋势\n\n- **开源追赶闭源**DeepSeek V4 性能接近 GPT-5,价格仅 1/50\n- **推理模型崛起**:思维链(CoT)成为标配\n- **多模态普及**:从"文本对话"到"看图、听音、读文档"\n- **Agent 化**:从回答问题到执行任务`,
contentType: 'article',
categoryId: defaultCatIds['ai-basics'],
tags: 'AI模型,格局,2026,开源,对比',
authorName: '宇之然研究院',
isAiGenerated: false,
publishedAt: new Date('2026-05-30'),
viewCount: 8764,
},
{
title: '零基础用 AI 做数据分析:从 Excel 到 Copilot',
summary: '不会 Python 也能做数据分析?2026 年 AI 让数据分析民主化。本文教你用自然语言完成数据分析和可视化。',
content: `## 传统方式 vs AI 方式\n\n**传统数据分析**:学习 Python + Pandas + Matplotlib = 3 个月\n**AI 辅助分析**:自然语言描述需求 = 3 分钟\n\n## 三步上手\n\n### 1. 数据准备\n将 CSV 或 Excel 直接上传到 AI 工具:\n- **ChatGPT Code Interpreter**:适合单次分析\n- **Gemini**:处理 Google Sheets 数据\n- **Copilot for Excel**:直接在 Excel 中用自然语言操作\n\n### 2. 用自然语言提问\n\n> "分析这个月各区域的销售额变化趋势,标记出增长最快的三个品类,用折线图展示"\n\nAI 会自动生成代码、执行分析、返回图表。\n\n### 3. 迭代优化\n\n> "把图表按季度分组,只显示华东和华南区域的数据"\n\n不需要写代码,就像跟一个数据分析师对话。\n\n## 实用场景\n\n| 场景 | AI 指令示例 |\n|------|------------|\n| 销售分析 | "上月各产品线增长率" |\n| 用户分析 | "用户的活跃度分布" |\n| 库存分析 | "哪些 SKU 周转最慢" |\n| 财务分析 | "本季度费用结构占比" |`,
contentType: 'tutorial',
categoryId: defaultCatIds['office-productivity'],
tags: '数据分析,Excel,AI,办公效率,零基础',
authorName: '宇之然数据分析团队',
isAiGenerated: false,
publishedAt: new Date('2026-06-08'),
viewCount: 5431,
},
{
title: 'AI 伦理:当算法决定谁进面试,我们该如何守护公平?',
summary: '2026 年多国 AI 法案全面生效,AI 公平性从"道德选择"变成"法律义务"。本文深入探讨 AI 决策中的伦理问题。',
content: `## 算法偏见案例\n\n2025 年,一家科技公司引入 AI 简历筛选后,女性候选人进面率从 38% 骤降到 12%。罪魁祸首不是恶意,而是历史数据中学到的偏见。\n\n## 三大偏见来源\n\n1. **训练数据偏差**:历史 hire 数据中某性别占比 85%,模型误以为性别是重要特征\n2. **特征选择偏差**:如"职业空窗期"对女性不公(因产假等)\n3. **标注偏差**:面试官的主观偏好通过标注传递给了模型\n\n## 欧盟 AI 法案的启示\n\n2026 年全面生效的欧盟 AI 法案将招聘 AI 归类为"高风险系统",要求:\n- 建立风险管理系统\n- 使用代表性训练数据\n- 确保人类审查权\n- 达到准确性和公平性标准\n- 违反将面临最高全球营收 **6% 的罚款**\n\n## 公平性建议\n\n1. 使用 **Demographic Parity** 等量化指标评估偏见\n2. 采用 **Adversarial Debiasing** 等去偏算法\n3. 定期进行**公平性审计**\n4. 保持**人在回路**Human-in-the-loop`,
contentType: 'article',
tags: 'AI伦理,偏见,公平性,法律,合规',
authorName: '宇之然研究院',
isAiGenerated: false,
publishedAt: new Date('2026-05-22'),
viewCount: 1987,
},
{
title: '中小企业 AI 落地指南:从 0 到 1 搭建 AI 工作流',
summary: '预算有限、没有 AI 团队的中小企业如何落地 AI?本文提供一套从选型到部署的完整方案,含工具推荐和 ROI 估算。',
content: `## 中小企业的 AI 困境\n\n- 78% 的大企业已系统化使用 AIMcKinsey 2026\n- 但只有 **23% 的中小企业**有 AI 落地计划\n- 最大障碍:**预算不足**和**人才缺失**\n\n## 低成本方案\n\n### 第一步:选对工具(预算:¥0-500/月)\n\n- **ChatGPT Plus**$20/月):通用助手\n- **Claude Pro**$20/月):深度分析\n- **DeepSeek**(按量):低成本 API\n\n### 第二步:找准场景(投入 2 周)\n\n优先落地**高频率、低风险**的场景:\n1. 客服话术生成 → 省时 10 小时/周\n2. 产品文案撰写 → 省时 15 小时/周\n3. 数据分析报表 → 省时 8 小时/周\n\n### 第三步:建立规范(投入 1 周)\n\n- 制定 AI 使用指南\n- 明确哪些数据不能输入 AI\n- 建立 AI 输出审核机制\n\n## 投资回报\n\n| 投入 | 月成本 | 月节省工时 | 折合价值 |\n|------|--------|-----------|---------|\n| AI 工具订阅 | ¥500 | 120 小时 | ≈¥30,000 |\n| 员工培训 | 一次性 ¥5,000 | - | - |\n| **投入产出比** | **1:60** | | |`,
contentType: 'tutorial',
categoryId: defaultCatIds['office-productivity'],
tags: '中小企业,落地,指南,工作流,AI',
authorName: '宇之然企业服务团队',
isAiGenerated: false,
publishedAt: new Date('2026-06-10'),
viewCount: 3890,
},
];
// First delete existing contents to re-seed (for idempotency)
await prisma.content.deleteMany();
for (const a of articles) {
await prisma.content.create({ data: a });
}
console.log(`${articles.length} articles`);
// ===== 3. More Prompts (12 additional) =====
console.log('Seeding additional prompts...');
const prompts = [
{ title: 'SWOT 分析生成器', content: '请对[公司/项目名称]进行 SWOT 分析。\n\n背景:[提供背景信息]\n\n要求:\n1. 从内部视角分析 Strengths(优势)和 Weaknesses(劣势)\n2. 从外部视角分析 Opportunities(机会)和 Threats(威胁)\n3. 每部分至少 3 点\n4. 最后给出 2-3 条可执行的战略建议', description: '快速生成专业的 SWOT 商业分析', categoryId: defaultCatIds['office-productivity'], tags: '商业分析,战略,SWOT', model: 'GPT-5', status: 'PUBLISHED' as const },
{ title: '代码评审助手', content: '请对以下代码进行 Code Review\n\n```\n[粘贴代码]\n```\n\n评审要求:\n1. 找出潜在的 Bug 和安全漏洞\n2. 检查代码风格和命名规范\n3. 建议性能优化方案\n4. 评估单元测试覆盖率\n5. 按严重程度分级(Critical / Major / Minor', description: 'AI 帮你做专业的代码评审', categoryId: defaultCatIds['programming'], tags: '编程,Code Review,代码质量', model: 'Claude 4', status: 'PUBLISHED' as const },
{ title: '社交媒体文案撰写', content: '请为[品牌/产品]撰写一条社交媒体推广文案。\n\n品牌:[品牌名]\n目标受众:[描述目标人群]\n推广内容:[要推广的内容]\n平台:[抖音/小红书/微信/微博]\n\n要求:\n1. 一个吸引眼球的标题(制造好奇或紧迫感)\n2. 正文 100-150 字,有画面感、有情绪\n3. 包含 1 个明确的 Call-to-Action\n4. 添加 3-5 个相关话题标签\n5. 符合所选平台的调性', description: '快速生成社交媒体运营文案', categoryId: defaultCatIds['creative-design'], tags: '社交媒体,文案,营销', model: '通用', status: 'PUBLISHED' as const },
{ title: '学习计划制定助手', content: '请帮我制定一个[学习目标]的学习计划。\n\n学习目标:[要学什么]\n现有基础:[描述当前水平]\n可用时间:[每周几个小时]\n学习周期:[几周/几个月]\n\n要求:\n1. 按周分阶段的详细计划\n2. 每阶段包含具体的学习资源和练习方式\n3. 包含阶段性成果检验方式\n4. 学习建议和常见陷阱提醒', description: 'AI 定制个人学习计划', categoryId: defaultCatIds['prompt-engineering'], tags: '学习,计划,效率', model: '通用', status: 'PUBLISHED' as const },
{ title: '用户画像生成器', content: '请根据以下产品信息生成[产品名]的目标用户画像。\n\n产品:[产品名]\n核心功能:[功能列表]\n定价:[价格区间]\n竞品:[竞品列表]\n\n请生成至少 3 个不同的用户画像,每个画像包含:\n1. 基本信息(年龄、职业、收入)\n2. 痛点和需求\n3. 使用场景\n4. 决策因素\n5. 一句话描述这个用户', description: '用 AI 构建精准的用户画像', categoryId: defaultCatIds['office-productivity'], tags: '用户画像,产品,营销', model: '通用', status: 'PUBLISHED' as const },
{ title: '英文邮件润色助手', content: '请帮我润色以下英文邮件,使其更加专业、地道。\n\n原文:\n[粘贴英文邮件]\n\n上下文:\n收件人:[客户/同事/老板]\n目的:[告知/请求/感谢/投诉]\n期望语气:[正式/半正式/友好]\n\n润色要求:\n1. 保留原始信息的完整性\n2. 使用更地道、专业的表达\n3. 优化邮件结构和逻辑\n4. 标注修改了哪些部分及原因', description: 'AI 帮你写专业英文邮件', categoryId: defaultCatIds['office-productivity'], tags: '英文,邮件,商务', model: 'GPT-5', status: 'PUBLISHED' as const },
{ title: '产品需求文档 (PRD) 生成', content: '请根据以下信息生成一份产品需求文档(PRD)。\n\n产品名称:[产品名]\n目标用户:[用户群体]\n核心问题:[要解决什么问题]\n核心功能:[功能列表]\n\n请按以下结构输出:\n1. 背景与目标\n2. 用户故事(至少 3 个)\n3. 功能需求(按优先级 P0/P1/P2)\n4. 非功能需求(性能、安全、可用性)\n5. 成功指标(OKR/KPI\n6. 开放问题', description: 'AI 辅助撰写产品需求文档', categoryId: defaultCatIds['office-productivity'], tags: '产品,PRD,需求文档', model: 'Claude 4', status: 'PUBLISHED' as const },
{ title: '面试模拟官', content: '请作为[岗位]面试官,对我进行模拟面试。\n\n岗位:[职位名称]\n我的经验:[X年经验,主要技能]\n面试类型:[技术/行为/综合]\n\n请逐个问题提问,我对每个问题回答后,你给出:\n1. 评分(1-5 分)\n2. 回答的亮点\n3. 可以改进的地方\n4. 参考答案要点\n\n总共问 5 个问题,从易到难。', description: 'AI 模拟面试官帮你准备面试', categoryId: defaultCatIds['prompt-engineering'], tags: '面试,求职,准备', model: '通用', status: 'PUBLISHED' as const },
{ title: '营销邮件自动化', content: '请为[活动/产品]撰写一封营销邮件。\n\n产品/活动:[名称]\n目标受众:[描述]\n核心卖点:[3 个核心价值]\nCTA:[希望用户做什么]\n\n邮件格式:\n1. 主题行(3 个选项,A/B 测试用)\n2. 预览文本(Preheader\n3. 正文(标题 + 痛点引入 + 解决方案 + 社会证明 + CTA\n4. P.S. 增加紧迫感', description: '生成高转化率的营销邮件', categoryId: defaultCatIds['creative-design'], tags: '营销,邮件,转化率', model: 'GPT-5', status: 'PUBLISHED' as const },
{ title: '竞品分析报告', content: '请对[行业/品类]进行竞品分析。\n\n需分析的竞品:[竞品 1,竞品 2,竞品 3]\n分析维度:\n1. 产品功能对比(用表格)\n2. 定价策略对比\n3. 目标用户对比\n4. 营销策略对比\n5. 优势和劣势总结\n6. 差异化机会建议', description: 'AI 生成专业的竞品分析', categoryId: defaultCatIds['office-productivity'], tags: '竞品分析,市场,产品', model: 'Claude 4', status: 'PUBLISHED' as const },
{ title: 'SQL 查询优化助手', content: '请帮我优化以下 SQL 查询:\n\n```sql\n[你的 SQL 语句]\n```\n\n数据库类型:[MySQL/PostgreSQL]\n数据量:[大约多少行]\n当前执行时间:[多少秒]\n期望目标:[如:1 秒以内]\n\n请给出:\n1. 当前 SQL 的性能问题分析\n2. 优化后的 SQL\n3. 建议添加的索引\n4. 预期性能提升', description: 'AI 帮你优化慢 SQL 查询', categoryId: defaultCatIds['programming'], tags: 'SQL,数据库,优化', model: 'Claude 4', status: 'PUBLISHED' as const },
{ title: 'API 文档生成器', content: '请根据以下代码/描述生成 API 文档:\n\n[粘贴 API 代码或描述]\n\n请按以下格式输出:\n1. 接口概述(用途、端点)\n2. 请求方法 + URL\n3. 请求头\n4. 请求参数(表格:参数名、类型、必填、说明)\n5. 请求示例(JSON)\n6. 成功响应示例\n7. 错误响应示例\n8. 调用限制说明', description: '自动生成结构化的 API 文档', categoryId: defaultCatIds['programming'], tags: 'API,文档,开发', model: '通用', status: 'PUBLISHED' as const },
];
await prisma.prompt.deleteMany();
for (const p of prompts) {
await prisma.prompt.create({ data: p });
}
console.log(`${prompts.length} prompts`);
// ===== 4. More Courses (3 additional courses) =====
console.log('Seeding additional courses...');
await prisma.course.deleteMany();
const courses = [
{
title: 'Prompt 工程:从入门到精通',
description: '系统学习提示词工程的核心框架、高级技巧和实战方法。课程包含 20+ 实战案例,覆盖写作、编程、分析等场景。',
categoryId: defaultCatIds['prompt-engineering'],
isFree: true,
status: 'PUBLISHED' as const,
sortOrder: 2,
chapters: {
create: [
{
title: '第一章:Prompt 工程基础',
sortOrder: 1,
lessons: {
create: [
{ title: '什么是 Prompt 工程', content: '# 什么是 Prompt 工程\n\nPrompt Engineering(提示词工程)是指设计和优化输入给 AI 模型的提示词,以获得期望输出的技术和方法论。\n\n## 为什么重要?\n\n- 同样的模型,不同的 Prompt 效果天差地别\n- 好的 Prompt = 精准的 AI 输出\n- 2026 年 Prompt 工程市场规模已达 $6.73 亿\n\n## 核心原则\n\n1. **清晰明确**:不要说"写点东西",要说"写一篇 300 字的电商产品介绍"\n2. **角色设定**:给 AI 一个角色("你是一名资深律师")\n3. **格式约束**:指定输出格式(JSON/表格/子弹列表)\n4. **示例引导**:给出 1-2 个例子(Few-Shot', sortOrder: 1, status: 'PUBLISHED' as const },
{ title: 'COSTAR 框架详解', content: '# COSTAR 框架\n\n这是 2026 年最主流的提示词框架:\n\n- **C**ontext(上下文):设定背景和角色\n- **O**bjective(目标):明确任务目标\n- **S**tyle(风格):指定输出风格\n- **T**one(语气):设定语气\n- **A**udience(受众):说明面向对象\n- **R**esponse(响应格式):指定格式\n\n## 实战案例\n\n**不好的 Prompt**\n> 帮我写一份产品介绍\n\n**COSTAR 优化后:**\n> Context:你是一家 AI 公司的产品经理\n> Objective:介绍一款 AI 写作助手给潜在企业客户\n> Style:专业且有说服力\n> Tone:热情但不浮夸\n> Audience:企业 CTO 和内容负责人\n> Response:分 5 个要点,每个不超过 50 字', sortOrder: 2, status: 'PUBLISHED' as const },
],
},
},
{
title: '第二章:高级技巧',
sortOrder: 2,
lessons: {
create: [
{ title: 'Chain-of-Thought 提示', content: '# Chain-of-Thought(思维链)\n\n让 AI 逐步推理再给出答案,显著提升复杂问题的准确率。\n\n## 示例\n\n**直接提问:**\n> Q:一个篮球和一副球拍总共 110 元,篮球比球拍贵 100 元,球拍多少钱?\n> A:10 元 ❌(常见错误答案)\n\n**CoT 方式:**\n> Q:一个篮球和一副球拍总共 110 元,篮球比球拍贵 100 元,球拍多少钱?\n> 让我们一步步思考:\n> 假设球拍价格为 x 元\n> 那么篮球价格为 x + 100 元\n> 总共:x + (x + 100) = 110\n> 2x = 10\n> x = 5\n> A:球拍 5 元 ✅', sortOrder: 1, status: 'PUBLISHED' as const },
{ title: 'Few-Shot 与角色扮演', content: '# Few-Shot Prompting\n\n在提示词中给出 2-3 个示例,让 AI 理解输出模式。\n\n## 示例\n\n> 将以下句子转换为正式通知语气:\n> 示例1:\n> 原文:明天放假\n> 转换后:经公司研究决定,明日(2026年3月15日)全体休假一天。\n>\n> 示例2\n> 原文:会议室有人吗\n> 转换后:请各同事注意,第三会议室将于今日下午2时进行设备检修,届时请勿占用。\n>\n> 原文:{你的句子}\n> 转换后:', sortOrder: 2, status: 'PUBLISHED' as const },
],
},
},
],
},
},
{
title: 'AI 办公效率提升实战',
description: '用 AI 提升日常办公效率:文档撰写、数据分析、会议管理、邮件处理。学完即可在工作中使用。',
categoryId: defaultCatIds['office-productivity'],
isFree: true,
status: 'PUBLISHED' as const,
sortOrder: 3,
chapters: {
create: [
{
title: '第一章:文档与写作',
sortOrder: 1,
lessons: {
create: [
{ title: 'AI 辅助撰写周报/日报', content: '# 用 AI 写周报\n\n## 常见痛点\n- 每周花 1 小时写周报\n- 不记得这周做了什么\n- 写出来没有亮点\n\n## AI 方法\n\n**Prompt 模板:**\n> 请帮我写一份本周工作总结。\n> 我的工作内容:[关键词]\n> 本周成果:[数据/项目]\n> 要求:按优先级排列,突出数据成果\n\n## 进阶技巧\n- 保持日常记录习惯(每天 2-3 条关键事项)\n- 用 AI 将零散记录整理为结构化周报\n- 让 AI 帮忙提炼"亮点"和"价值"', sortOrder: 1, status: 'PUBLISHED' as const },
{ title: 'AI 会议纪要生成', content: '# AI 会议纪要\n\n## 传统方式\n- 手动录音 → 回听 → 整理 → 1 小时会议需要 2 小时整理\n\n## AI 方式\n- 实时转写 → AI 提取要点 → 自动分发给参会人 → 3 分钟完成\n\n## 必备工具\n1. **会议记录工具**:通义听悟、飞书妙记\n2. **AI 整理 Prompt**\n > 请为以下会议内容生成纪要:\n > [粘贴转写文字]\n > 格式:时间、参会人、议题、讨论要点、待办事项及负责人', sortOrder: 2, status: 'PUBLISHED' as const },
],
},
},
{
title: '第二章:数据处理',
sortOrder: 2,
lessons: {
create: [
{ title: 'AI 辅助 Excel 数据分析', content: '# AI + Excel = 10 倍效率\n\n## 场景:销售数据分析\n\n不用学 VLOOKUP 和数据透视表,直接用自然语言让 AI 帮你:\n\n> "帮我分析这份销售数据:\n> 1. 按区域汇总 2025 年 Q4 销售额\n> 2. 同比增长率\n> 3. 找出增长最快和最慢的品类\n> 4. 用条件格式标出超过平均值的单元格"\n\n## 推荐工具\n- Microsoft Copilot for Excel\n- ChatGPT Code Interpreter\n- Google Gemini with Sheets', sortOrder: 1, status: 'PUBLISHED' as const },
],
},
},
],
},
},
{
title: 'AI 编程助手实战',
description: '2026 年开发者必备的 AI 编程技能。学习使用 Cursor、GitHub Copilot 等工具,将编码效率提升 2-3 倍。',
categoryId: defaultCatIds['programming'],
isFree: false,
price: 49.9,
status: 'PUBLISHED' as const,
sortOrder: 4,
chapters: {
create: [
{
title: '第一章:AI 编程工具概览',
sortOrder: 1,
lessons: {
create: [
{ title: 'CursorAI 原生 IDE', content: '# Cursor 使用指南\n\n## 核心功能\n\n1. **Tab 补全**AI 预测你的下一步操作\n2. **Cmd+K 内联编辑**:选中代码后用自然语言修改\n3. **Chat 面板**:对话式编程助手\n4. **Composer**:多文件协同编辑\n\n## 快速上手\n\n```bash\n# 安装\nbrew install --cask cursor\n# 或从 cursor.com 下载\n```\n\n## 必备快捷键\n| 快捷键 | 功能 |\n|--------|------|\n| Cmd+I | 打开 Composer |\n| Cmd+L | 打开 Chat |\n| Cmd+K | 内联编辑 |\n| Tab | 接受补全 |', sortOrder: 1, status: 'PUBLISHED' as const },
{ title: 'GitHub Copilot 进阶玩法', content: '# GitHub Copilot 高级技巧\n\n## Agent Mode\n\nCopilot 的 Agent Mode 可以自主执行多步骤任务:\n\n> "创建一个 RESTful API 端点,包含输入验证、错误处理和单元测试"\n\nCopilot 会:\n1. 创建路由文件\n2. 编写控制器逻辑\n3. 添加输入校验\n4. 编写测试用例\n5. 更新 API 文档\n\n## 代码审查\n\n在 PR 中使用 Copilot 自动审查:\n- 检查安全漏洞\n- 识别性能问题\n- 建议代码改进', sortOrder: 2, status: 'PUBLISHED' as const },
],
},
},
],
},
},
];
for (const courseData of courses) {
await prisma.course.create({ data: courseData });
}
console.log(`${courses.length} courses`);
// ===== 5. Banners (3 banners) =====
console.log('Seeding banners...');
const banners = [
{ title: 'AI 练习场 - 免费实战', image: '/images/banner-practice.jpg', link: '/practices', position: 'home', sortOrder: 1, status: 'PUBLISHED' },
{ title: '企业 AI 培训方案', image: '/images/banner-enterprise.jpg', link: '/enterprise', position: 'home', sortOrder: 2, status: 'PUBLISHED' },
{ title: 'AI 模型百科', image: '/images/banner-models.jpg', link: '/models', position: 'home', sortOrder: 3, status: 'PUBLISHED' },
];
await prisma.banner.deleteMany();
for (const b of banners) {
await prisma.banner.create({ data: b });
}
console.log(`${banners.length} banners`);
// ===== 6. More Tools (6 more) =====
console.log('Seeding additional tools...');
await prisma.tool.deleteMany();
const tools = [
{ name: 'DeepSeek', description: '深度求索开发的 AI 大模型,中文能力突出,API 价格极低。支持开源模型私有化部署,适合数据敏感场景。', url: 'https://chat.deepseek.com', categoryId: defaultCatIds['ai-tools'], tags: '对话,开源,中文,低成本', isFeatured: true, status: 'PUBLISHED' as const },
{ name: 'Midjourney', description: '领先的 AI 图像生成工具,以高质量艺术风格著称。支持文本生成图像、图像变体、风格迁移等。', url: 'https://midjourney.com', categoryId: defaultCatIds['ai-tools'], tags: '图像,创意,设计', isFeatured: true, status: 'PUBLISHED' as const },
{ name: 'GitHub Copilot', description: '微软与 GitHub 推出的 AI 编程助手,支持 VS Code、JetBrains 等主流 IDE。可自动补全代码、生成函数、编写测试。', url: 'https://github.com/features/copilot', categoryId: defaultCatIds['ai-tools'], tags: '编程,代码,开发', isFeatured: true, status: 'PUBLISHED' as const },
{ name: 'Cursor', description: '2026 年增长最快的 AI 原生代码编辑器,深度集成 AI 对话、代码生成和智能补全。VS Code 用户零迁移成本。', url: 'https://cursor.sh', categoryId: defaultCatIds['ai-tools'], tags: '编程,IDE,AI原生', isFeatured: true, status: 'PUBLISHED' as const },
{ name: 'Perplexity AI', description: 'AI 搜索引擎,结合实时网络搜索和大语言模型能力。提供可溯源的回答,附带来源引用。', url: 'https://perplexity.ai', categoryId: defaultCatIds['ai-tools'], tags: '搜索,研究,实时信息', isFeatured: true, status: 'PUBLISHED' as const },
{ name: 'Runway Gen-4', description: '领先的 AI 视频生成工具,支持文本生成视频、图像生成视频、视频编辑等。好莱坞级视频制作能力。', url: 'https://runwayml.com', categoryId: defaultCatIds['ai-tools'], tags: '视频,创意,生成', status: 'PUBLISHED' as const },
];
for (const t of tools) {
await prisma.tool.create({ data: t });
}
console.log(`${tools.length} tools`);
// ===== 7. Community Posts (5 demo posts) =====
console.log('Seeding community posts...');
await prisma.post.deleteMany();
await prisma.circle.deleteMany();
const demoUser = await prisma.user.findFirst();
if (demoUser) {
// Create a circle first
const circle = await prisma.circle.create({
data: {
name: 'AI 学习交流圈',
description: 'AI 学习者的交流社区,分享学习心得、工具推荐和行业见解。',
tags: 'AI,学习,交流',
creatorId: demoUser.id,
isPublic: true,
},
});
const posts = [
{ title: '分享:我用 GPT-5.4 和 Claude 4.6 做同一个数据分析任务的对比', content: '最近公司要做用户留存分析,我用 GPT-5.4 和 Claude 4.6 分别试了一下,结果很有意思:\n\n**GPT-5.4**:速度快,82 tokens/s,直接给出了 Python 代码和可执行的方案\n**Claude 4.6**:分析更深入,给了我 5 个不同的分析角度,很多我自己都没想到\n\n结论:GPT 适合快速出活,Claude 适合深度分析。建议两个都用,互补效果最好。\n\n大家有什么发现也来分享一下?', tags: 'GPT,Claude,数据分析,对比', circleId: circle.id, userId: demoUser.id, viewCount: 342, likeCount: 28, commentCount: 7 },
{ title: '零基础入门 AI 三个月,我做了什么', content: '三个月前我还是个 AI 小白,现在可以熟练用 AI 辅助工作。分享一下我的学习路径:\n\n**第一个月**:了解基础概念\n- 看了 Stanford HAI 的 AI Index 报告\n- 学会了写基础的 Prompt\n\n**第二个月**:开始实战\n- 用 AI 做数据分析、写周报、整理会议纪要\n- 每天至少用 AI 完成 3 个小任务\n\n**第三个月**:深入场景\n- 学习 Cursor 和 Copilot 辅助编程\n- 用 AI 做产品分析和市场调研\n\n最大的感受:不要追求一步到位,从最常用的小场景开始,慢慢扩展。', tags: '入门,学习路径,经验分享', circleId: circle.id, userId: demoUser.id, viewCount: 567, likeCount: 45, commentCount: 12 },
{ title: '2026 年最推荐的 AI 学习资源清单', content: '整理了 2026 年最值得看的 AI 学习资源:\n\n**免费课程**\n1. Stanford HAI AI Index Report(年度必读)\n2. DeepLearning.AI 的 Prompt Engineering 课程\n3. 宇之然 AI 平台的练习场(免费实战)\n\n**实用工具**\n1. ChatGPT Plus / Claude Pro(每天练手)\n2. Cursor(编程必装)\n3. Perplexity(研究必备)\n\n**社区**\n1. Reddit r/artificial\n2. 宇之然 AI 交流圈(就是这里)\n3. Devpost(参与 AI 黑客松)\n\n大家有补充的吗?', tags: '学习资源,推荐,2026', circleId: circle.id, userId: demoUser.id, viewCount: 423, likeCount: 36, commentCount: 9 },
{ title: '用 DeepSeek V4 做微调的经验分享', content: '最近在做一个垂直场景的项目,试了 DeepSeek V4 的微调,分享一些实际体验:\n\n**优点**\n- 价格真便宜,API 只有 GPT 的 1/50\n- 中文效果出乎意料的好\n- 开源可私有化部署,数据安全有保障\n\n**缺点**\n- 英文场景偶尔不如 GPT/Claude\n- 上下文 128K 对于超长文档处理有点勉强\n- 生态工具链不如 OpenAI 丰富\n\n**建议**\n适合预算有限的团队和中文场景,或者数据敏感的企业私有化部署。如果需要最强性能,还是选 GPT-5.4 或 Claude 4.6。', tags: 'DeepSeek,微调,模型,开源', circleId: circle.id, userId: demoUser.id, viewCount: 289, likeCount: 21, commentCount: 5 },
{ title: '我认为 AI 不会取代程序员,但会取代不会用 AI 的程序员', content: '这句话虽然被说烂了,但 2026 年的数据确实在验证这个观点:\n\n- Devpost 报告:参与 6 次以上黑客松的开发者,项目上线率是第一年的 2.2 倍\n- McKinsey78% 的企业已在用 AI\n- 平均每个开发者使用 2-3 个 AI 工具\n\n真正的问题不是"AI 会不会取代我",而是"怎么用 AI 放大自己的能力"\n\n1. 用 Copilot 写重复代码,把精力放在架构设计上\n2. 用 AI 做测试用例生成,把时间留给 Code Review\n3. 用 AI 写文档,让自己更专注核心逻辑\n\n建议:每天花 30 分钟探索 AI 能帮你做什么,持续 3 个月。', tags: '程序员,AI,职业发展,观点', circleId: circle.id, userId: demoUser.id, viewCount: 892, likeCount: 67, commentCount: 23 },
];
for (const post of posts) {
await prisma.post.create({ data: post });
}
console.log(`${posts.length} community posts`);
}
console.log('\n✓ Enrichment complete!');
}
main()
.catch((e) => { console.error(e); process.exit(1); })
.finally(async () => { await prisma.$disconnect(); });
+136
View File
@@ -0,0 +1,136 @@
const { PrismaClient } = require('@prisma/client');
const prisma = new PrismaClient();
const skills = [
{
id: 'payment-chaser',
name: '回款助手',
description: 'AI 追款邮件生成器 — 输入发票信息,自动生成礼貌→坚定→最后的催款邮件序列',
icon: '💰',
category: 'career',
difficulty: 'beginner',
price: 19.9,
sortOrder: 10,
systemPrompt: '你是一位专业的应收账款催收专家,擅长在不损害客户关系的前提下高效追款。\n\n## 核心原则\n1. 语气逐步升级:礼貌提醒 → 友好追问 → 坚定催促 → 最后通牒\n2. 每一封邮件必须包含:发票号、金额、到期日、支付方式链接\n3. 保持专业但友好的语气,给客户留面子\n4. 提供具体的行动指引(点击链接支付、回复确认等)\n5. 英文邮件为主,可根据需要输出中文\n\n## 输出格式\n根据输入的发票信息和当前阶段,生成完整邮件(含主题行、正文、署名)。\n如果第一封未回复,自动生成第2-4封跟进邮件。\n\n用户输入格式参考:客户名称 | 发票号 | 金额 | 到期日 | 项目描述 | 当前阶段(首次/提醒/催促/最后)',
starters: JSON.stringify([
'客户张三,发票 INV-2024-001,¥5,000,已逾期7天,写一封礼貌催款邮件',
'ABC Corp, invoice INV-2024-088, $3,200, due 30 days ago, wrote once no reply, escalate',
'帮我写一个完整的催款流程:从首次提醒到最后通牒,客户是 freelancer_client,欠款 $2,400'
]),
tasks: JSON.stringify([
{ label: '首次催款', prompt: '客户王磊,发票 INV-2024-012,¥3,500,昨天到期。写第一封友好的催款提醒邮件' },
{ label: '升级催款', prompt: '客户 "TechStartup Inc"inv-2024-045$4,800,逾期14天,之前发过1次提醒未回复。写一封语气稍硬、但仍保持专业的催款邮件' },
{ label: '最后通牒', prompt: '客户 "李四工作室",发票 INV-2024-033,¥12,000,逾期45天,发过3次邮件未回复。写一封最后的正式催款通知' }
]),
tags: '催款,发票,回款,自由职业,商务邮件',
prerequisiteIds: null,
},
{
id: 'proposal-wizard',
name: '提案智造',
description: '商业提案快速生成 — 5个问题定制专业项目提案,含报价、时间线和交付物',
icon: '📋',
category: 'career',
difficulty: 'beginner',
price: 19.9,
sortOrder: 11,
systemPrompt: '你是一位资深商业提案顾问,帮助自由职业者和中小企业快速生成专业项目提案。\n\n## 核心原则\n1. 根据用户提供的项目信息,自动填充标准提案框架\n2. 提案结构:项目概述 → 需求理解 → 解决方案 → 时间线 → 报价 → 条款\n3. 强调用户价值(ROI),不只是罗列功能\n4. 报价部分要有 2-3 档选项(基础/推荐/豪华)\n5. 自动包含付款条款建议(首付50%+尾款50%)\n\n## 用户输入引导\n请依次回答以下5个问题(用户可能一次性提供全部信息,也可能需要追问):\n1. 项目名称和一句话描述?\n2. 客户是谁?行业/规模?\n3. 具体要交付什么?\n4. 时间要求?\n5. 预算范围?\n\n## 输出\n生成完整提案文本,可用 Markdown 格式直接复制到文档中。',
starters: JSON.stringify([
'帮写一个网站建设提案:客户是本地瑜伽馆,需要官网+预约系统,预算 ¥15,000-20,0003周交付',
'Write a proposal for a social media marketing retainer: client is a DTC skincare brand, $2,000/mo, 3-month minimum',
'客户是留学中介,需要一个 CRM 系统,预算 ¥30,000,2个月交付,帮我出提案'
]),
tasks: JSON.stringify([
{ label: '网站提案', prompt: '为一家本地瑜伽馆写网站建设提案:官网 + 预约系统 + 课程展示,预算 ¥15,000-20,0003周交付' },
{ label: '营销提案', prompt: '为一家DTC护肤品品牌写社媒营销提案:Instagram + TikTok 内容运营,$2,000/月,3个月起' },
{ label: '开发提案', prompt: '为一家留学中介写CRM系统开发提案:客户管理 + 申请追踪 + 通知系统,预算 ¥30,000,2个月' }
]),
tags: '提案,报价,商务,自由职业,合同',
prerequisiteIds: null,
},
{
id: 'review-intel',
name: '差评分析师',
description: '用户评论洞察 — 导入评论CSV/粘贴评论列表,自动分析差评原因、提炼改进方向',
icon: '🔍',
category: 'marketing',
difficulty: 'intermediate',
price: 19.9,
sortOrder: 12,
systemPrompt: '你是一位用户研究专家和产品分析师,擅长从用户评论中提取可执行的洞察。\n\n## 核心原则\n1. 对评论进行情感分类:正面/中性/负面\n2. 对负面评论进行主题聚类(如:发货慢、质量差、客服差、尺寸不准等)\n3. 统计每个主题的频率和严重程度\n4. 输出按优先级排序的改进建议\n5. 支持中文和英文评论混合输入\n\n## 输入格式\n用户粘贴评论列表(每行一条),或 CSV 格式。\n\n## 输出格式\n1. 📊 总览:评论总数,好评率,差评率\n2. 🔥 差评TOP主题(按频次排序)\n - 主题名:出现次数,示例评论\n3. ✅ 改进建议(按优先级)\n - 高优先级:[建议] — 预期影响\n - 中优先级:[建议]\n4. 💡 机会发现:用户隐性需求',
starters: JSON.stringify([
'分析这些亚马逊评论:Product arrived 3 days late. The material feels cheap. Zipper broke after 2 uses.',
'分析这些淘宝差评:衣服色差很大,跟图片完全不一样。尺码偏小,建议买大一码。发货太慢了等了一个星期。客服态度很差问了半天不回复',
'帮我设计一个评论分析模板,适合 Shopify 店铺批量分析'
]),
tasks: JSON.stringify([
{ label: '亚马逊评论分析', prompt: '分析以下评论:\n"Product arrived damaged. Box was crushed."\n"Love this! Perfect size and color."\n"Battery dies too fast. Not worth the money."\n"Instructions were unclear, hard to assemble."\n"Shipping was fast but product quality is mediocre."' },
{ label: '淘宝评论分析', prompt: '分析评论:衣服质量不错但味道很大晒了三天才好。跟图片一致性价比高。客服回复很慢。拉链用了两次就坏了。物流很快隔天到。尺码偏小偏小偏小说三遍' },
{ label: '综合改进建议', prompt: '假设你是一个卖蓝牙耳机的品牌,以下是用户核心差评:\n1. 续航不足(40%差评提到)\n2. 连接不稳定(25%)\n3. 佩戴舒适度差(20%)\n4. 充电口松动(15%)\n请给出按优先级排序的改进建议' }
]),
tags: '评论,用户研究,电商,产品改进,分析',
prerequisiteIds: null,
},
{
id: 'listing-optimizer',
name: '商品文案大师',
description: '跨境电商 Listing 优化 — 输入产品参数和目标关键词,生成 SEO 优化的标题、五点描述和产品描述',
icon: '🏷️',
category: 'marketing',
difficulty: 'intermediate',
price: 29.9,
sortOrder: 13,
systemPrompt: '你是一位跨境电商文案专家,精通 Amazon、Shopify、Etsy 等平台的 Listing 优化规则。\n\n## 核心原则\n1. 标题:核心关键词前置,品牌+核心词+特性+规格+使用场景\n2. 五点描述:每一点解决一个用户痛点或突出一个卖点\n3. 产品描述:讲故事,让用户想象使用场景\n4. 后台关键词:包含同义词、拼写变体、长尾词\n5. 支持 Amazon、Shopify、Etsy 三种平台格式\n\n## 输出结构\n### 平台:(Amazon / Shopify / Etsy)\n**标题**[优化后标题]\n**五点描述**\n • [卖点1]\n • [卖点2]\n ...\n**产品描述**[完整的描述段落]\n**后台关键词**:[逗号分隔的关键词]\n\n如果用户没有指定平台,默认 Amazon 格式。',
starters: JSON.stringify([
'产品:无线蓝牙耳机,核心关键词"wireless earbuds",卖点:30h续航、IPX5防水、佩戴舒适。写 Amazon listing',
'产品:瑜伽垫,关键词"yoga mat",卖点:防滑、环保TPE材质、加厚6mm。写 Etsy listing',
'产品:便携咖啡杯,350ml,双层隔热,304不锈钢,防漏。写 Amazon listing'
]),
tasks: JSON.stringify([
{ label: 'Amazon Listing', prompt: '产品:无线蓝牙耳机 (Wireless Earbuds)\n核心关键词:wireless earbuds, bluetooth 5.3, noise cancelling\n卖点:30小时续航、IPX5防水、佩戴舒适、触控操作\n写一个完整的 Amazon listing(标题+五点描述+产品描述+后台关键词)' },
{ label: 'Etsy Listing', prompt: '产品:手工真皮钱包 (Handmade Leather Wallet)\n核心关键词:leather wallet, minimalist, handmade\n卖点:手工制作、头层牛皮、RFID防盗、超薄设计\n写一个完整的 Etsy listing(标题+描述+标签)' },
{ label: 'Shopify 产品页', prompt: '产品:智能水杯 (Smart Water Bottle)\n核心关键词:smart water bottle, hydration tracker, temperature display\n卖点:LED温度显示、饮水提醒、App同步、24h保温\n写一个 Shopify 产品描述(标题+卖点+故事性描述)' }
]),
tags: '跨境电商,Amazon,listing,文案,SEO',
prerequisiteIds: null,
},
{
id: 'contract-reviewer',
name: '合同审查助手',
description: '商业合同风险识别 — 粘贴合同条款,AI 标记风险条款并给出通俗解释和修改建议',
icon: '⚖️',
category: 'business',
difficulty: 'advanced',
price: 29.9,
sortOrder: 14,
systemPrompt: '你是一位商业法律顾问,专注于帮助中小企业理解合同条款并识别风险。\n\n注意:你提供的是合同解读和风险提示,不构成正式法律意见。建议用户对重大合同咨询专业律师。\n\n## 核心原则\n1. 逐段解读合同条款,用通俗语言说明含义\n2. 标记风险等级:🔴 高风险 / 🟡 注意 / 🟢 正常\n3. 常见风险条款:自动续约、免责过宽、赔偿上限过低、管辖地不利、单方变更权\n4. 对每条风险条款给出:为什么有问题 + 建议修改方案\n5. 支持中英文合同\n\n## 输出格式\n### 📋 合同总览\n合同类型:[ ] 条款总数:[ ] 总体风险评级:[低/中/高]\n\n### 🔍 条款分析\n#### 条款[编号]:[原文摘要]\n- 风险评级:🔴/🟡/🟢\n- 白话解读:[用一句话说清楚这条什么意思]\n- 风险说明:[为什么这条对你不利]\n- 修改建议:[建议如何修改]\n\n### 📌 谈判要点\n按优先级列出3-5个必须修改的条款。',
starters: JSON.stringify([
'审查这份服务协议:服务方有权在任何时候修改条款,修改后继续使用即视为同意。服务方对任何间接损失不承担责任。',
'Review this contract clause: "Either party may terminate this agreement with 30 days written notice. Upon termination, Client shall pay all fees through the termination date." Is this fair?',
'帮我审查一份 SaaS 订阅合同中的自动续约和赔偿条款'
]),
tasks: JSON.stringify([
{ label: '服务协议审查', prompt: '审查以下条款:\n1. "服务方有权在任何时候修改服务条款,修改后通过网站公布即生效,继续使用视为接受"\n2. "服务方对因使用本服务产生的任何间接、附带或惩罚性赔偿不承担责任"\n3. "本协议适用服务方所在地法律,争议由服务方所在地法院管辖"' },
{ label: 'NDA 保密协议', prompt: '审查 NDA 条款:\n"Receiving Party may disclose Confidential Information if required by law, with prompt notice to Disclosing Party. Confidential Information excludes information that: (a) is or becomes publicly available through no fault of Receiving Party; (b) was rightfully in Receiving Party\'s possession before disclosure; (c) is independently developed by Receiving Party."' },
{ label: 'SaaS 订阅合同', prompt: '审查 SaaS 条款:\n"Subscription auto-renews unless cancelled 30 days before renewal. Fees may increase up to 10% annually. Provider may suspend service for non-payment with 5 days notice. Data will be deleted 30 days after termination. Liability cap equals 3 months of fees."' }
]),
tags: '合同,法律,风险,商务,审查',
prerequisiteIds: null,
},
];
async function main() {
for (const skill of skills) {
await prisma.skill.upsert({
where: { id: skill.id },
create: skill,
update: skill,
});
console.log(`${skill.id} (${skill.name}) — ¥${skill.price}`);
}
console.log(`\n🎉 ${skills.length} skills seeded successfully!`);
}
main()
.catch(e => { console.error(e); process.exit(1); })
.finally(() => prisma.$disconnect());
+348
View File
@@ -0,0 +1,348 @@
import { PrismaClient } from '@prisma/client';
const prisma = new PrismaClient();
const practices = [
{
title: '客户服务邮件回复',
scenario: '你是一家电商公司的客服主管。一位客户发来邮件投诉:他购买的商品延迟了 5 天仍未发货,且客服电话一直打不通。他要求立即退款并赔偿损失。',
description: '模拟客服场景:处理客户投诉邮件,需要体现共情能力、问题解决能力和专业性。',
instructions: '请你以客服主管的身份,撰写一封回复邮件。要求:1. 首先表达歉意和理解 2. 解释原因(库存系统故障)3. 给出具体解决方案(加急发货 + 优惠券补偿)4. 保持专业礼貌的语气。邮件长度 200-300 字。',
difficulty: 'BEGINNER',
category: 'customer',
expectedCriteria: JSON.stringify([
{ name: '共情表达', maxScore: 20, description: '是否对客户的不满表达了理解和歉意' },
{ name: '解决方案', maxScore: 30, description: '是否提供了具体、可行的补偿方案' },
{ name: '专业性', maxScore: 25, description: '语气是否专业礼貌,格式是否规范' },
{ name: '完整性', maxScore: 25, description: '是否涵盖了问题说明、解决方案和后续跟进' },
]),
hint: '可以先表达感谢客户的反馈,然后说明调查结果,再给出具体补偿方案,最后承诺改进。',
sortOrder: 1,
},
{
title: 'SQL 数据查询优化',
scenario: '你是一个数据分析师。公司的订单表 orders 已有 500 万条数据。产品经理需要查询"过去 30 天内,每个分类下销售额最高的前 5 个商品"。现有查询执行需要 45 秒,严重超时。',
description: '数据库查询优化场景:分析慢查询并给出优化方案。',
instructions: '请分析以下原始 SQL 的问题,并给出优化后的查询:\n\n原始 SQL\nSELECT * FROM orders o JOIN products p ON o.product_id = p.id JOIN categories c ON p.category_id = c.id WHERE o.created_at > DATE_SUB(NOW(), INTERVAL 30 DAY) GROUP BY c.name, p.name ORDER BY SUM(o.amount) DESC;\n\n要求:1. 指出至少 2 个性能问题 2. 给出优化后的 SQL 3. 建议必要的索引 4. 解释优化原理',
difficulty: 'INTERMEDIATE',
category: 'analysis',
expectedCriteria: JSON.stringify([
{ name: '问题诊断', maxScore: 25, description: '是否准确指出了慢查询的根因' },
{ name: 'SQL 优化', maxScore: 35, description: '优化后的 SQL 是否正确且高效' },
{ name: '索引建议', maxScore: 25, description: '索引建议是否合理' },
{ name: '原理解释', maxScore: 15, description: '优化原理解释是否清晰' },
]),
hint: '思考:SELECT * 是否必要?GROUP BY 的字段是否都有索引?子查询或窗口函数是否更适合?',
sortOrder: 2,
},
{
title: '产品文案撰写',
scenario: '一家新兴的智能水杯创业公司需要为他们的旗舰产品撰写电商详情页文案。产品卖点:48 小时保温、智能水温显示、杯盖温度触控、500ml 容量、食品级 316 不锈钢、支持 APP 记录饮水量。目标用户是 25-40 岁的都市白领。',
description: '电商文案写作场景:针对目标用户撰写有说服力的产品文案。',
instructions: '请撰写一段产品详情页的"核心卖点"部分文案(300-400 字)。要求:1. 突出 2-3 个核心差异化卖点 2. 针对都市白领的痛点(忙碌、注重健康、追求品质)3. 使用有画面感的语言 4. 包含一个吸引人的标题。',
difficulty: 'BEGINNER',
category: 'writing',
expectedCriteria: JSON.stringify([
{ name: '卖点提炼', maxScore: 25, description: '是否准确提炼了核心差异化卖点' },
{ name: '用户洞察', maxScore: 25, description: '是否针对目标用户的痛点和需求' },
{ name: '语言表达', maxScore: 30, description: '语言是否生动有感染力' },
{ name: '结构逻辑', maxScore: 20, description: '文案结构是否清晰有说服力' },
]),
hint: '想想都市白领的一天:早上匆忙出门需要热水、下午开会时想喝温水、晚上健身需要补水。从场景出发更容易打动人心。',
sortOrder: 3,
},
{
title: 'Python 数据分析脚本',
scenario: '市场部给你一份 CSV 文件(sales_2024.csv),包含全年销售数据:日期、产品、区域、销售额、数量。市场总监需要一份按月的各区域销售趋势报表,同时标识出同比增长超过 20% 的明星产品。',
description: '数据分析编程场景:用 Python 处理真实业务数据。',
instructions: '请编写一个 Python 脚本来完成以下任务:\n1. 读取 CSV 并按月份和区域汇总销售额\n2. 计算每个产品相对于去年同期的增长率\n3. 标记增长率 > 20% 的产品为"明星产品"\n4. 生成一个可视化图表(按月/区域的热力图)\n5. 输出汇总报表到 Excel\n\n要求:使用 pandas + matplotlib,代码要有注释,考虑异常处理。',
difficulty: 'ADVANCED',
category: 'coding',
expectedCriteria: JSON.stringify([
{ name: '功能完整性', maxScore: 30, description: '是否完成了所有要求的 5 个任务' },
{ name: '代码质量', maxScore: 25, description: '代码是否规范、可读性强、有注释' },
{ name: '数据处理', maxScore: 25, description: '数据聚合和计算逻辑是否正确' },
{ name: '异常处理', maxScore: 20, description: '是否考虑了文件缺失、数据格式等异常' },
]),
hint: '使用 pd.read_csv() 读取,pd.to_datetime() 处理日期,df.pivot_table() 做透视表。同比增长需要先按年月分组,然后用 shift() 获取同期数据。',
sortOrder: 4,
},
{
title: 'Prompt 优化:简历筛选助手',
scenario: '你是一个 HR 技术经理。公司要招聘一名高级前端工程师,你准备让 AI 助手帮忙初步筛选候选人简历。但你发现直接用"帮我筛选简历"得到的回复非常笼统,不够专业。',
description: '提示词工程场景:设计一个专业、精准的简历筛选 Prompt。',
instructions: '请设计一个用于 AI 简历筛选的系统级提示词(System Prompt)。要求:\n1. 设定 AI 的角色为"资深前端技术面试官"\n2. 明确评估维度(技术栈匹配度、项目经验质量、工作年限、教育背景、软技能)\n3. 要求 AI 按 5 分制评分并给出具体理由\n4. 设置输出格式为结构化 JSON\n5. 给出一个使用你设计的 Prompt 筛选简历的示例输出',
difficulty: 'INTERMEDIATE',
category: 'general',
expectedCriteria: JSON.stringify([
{ name: '角色设定', maxScore: 20, description: '角色设定是否清晰专业' },
{ name: '评估维度', maxScore: 25, description: '评估维度是否全面合理' },
{ name: '评分标准', maxScore: 25, description: '评分标准是否可量化、可操作' },
{ name: '输出格式', maxScore: 30, description: '输出格式是否结构化、便于后续处理' },
]),
hint: '好的 System Prompt 包含:角色 + 任务 + 要求 + 输出格式 + 约束条件。行业最佳实践是用 COSTAR 框架(Context, Objective, Style, Tone, Audience, Response)。',
sortOrder: 5,
},
{
title: 'A/B 测试方案设计',
scenario: '你们产品的注册转化率目前是 2.3%。产品经理建议将注册按钮从"注册"改为"免费开始使用",并猜测这样可以提升转化率到 3.5%。CEO 要求你设计一个完整的 A/B 测试方案来验证这个假设。',
description: '产品分析场景:设计严谨的 A/B 测试方案验证业务假设。',
instructions: '请设计一个完整的 A/B 测试方案。要求包含:\n1. 明确的原假设和备择假设\n2. 样本量计算(α=0.05, β=0.2)\n3. 实验分组策略(流量分配、随机化方法)\n4. 实验周期和决策标准\n5. 需要考虑的潜在偏差和缓解措施\n6. 评估指标(主指标 + 辅助指标 + 护栏指标)',
difficulty: 'ADVANCED',
category: 'analysis',
expectedCriteria: JSON.stringify([
{ name: '假设检验', maxScore: 20, description: '假设设定是否准确,样本量计算是否正确' },
{ name: '实验设计', maxScore: 30, description: '分组策略、周期和决策标准是否科学' },
{ name: '偏差控制', maxScore: 25, description: '是否识别了关键偏差并给出缓解方案' },
{ name: '指标体系', maxScore: 25, description: '指标体系是否完整(主/辅/护栏)' },
]),
hint: '样本量公式:n = (Z_α/2 + Z_β)² * (p1(1-p1) + p2(1-p2)) / (p2-p1)²。注意需要考虑新奇效应(Novelty Effect)和交互效应。',
sortOrder: 6,
},
{
title: '技术方案设计:短链接服务',
scenario: '公司需要内部搭建一个短链接服务,用于营销短信中的链接跳转。预计 QPS 峰值 5000,需要支持链接点击统计和过期时间设置。你作为后端技术负责人需要输出技术方案。',
description: '系统设计场景:设计一个高可用的短链接服务架构。',
instructions: '请设计一个短链接服务的技术方案。要求包含:\n1. 架构图描述(组件、数据流)\n2. 短链接生成算法(考虑冲突和性能)\n3. 数据库表结构设计\n4. 重定向流程(301 vs 302 的选择)\n5. 高可用方案(缓存、容灾)\n6. 点击统计的实现方案',
difficulty: 'ADVANCED',
category: 'coding',
expectedCriteria: JSON.stringify([
{ name: '架构设计', maxScore: 25, description: '架构是否合理,组件划分是否清晰' },
{ name: '算法设计', maxScore: 20, description: '短链生成算法是否高效、无冲突' },
{ name: '数据存储', maxScore: 20, description: '表结构设计和缓存策略是否合理' },
{ name: '高可用', maxScore: 20, description: '高可用和容灾方案是否完善' },
{ name: '统计方案', maxScore: 15, description: '点击统计方案是否准确可靠' },
]),
hint: '短链生成可以用 62 进制编码(a-z A-Z 0-9),也可以用雪花算法取后 7 位。读请求用 Redis 缓存,写请求通过 MQ 异步落库。',
sortOrder: 7,
},
{
title: '创意营销:AI 产品上市方案',
scenario: '公司即将推出一款 AI 写作助手产品"文思",目标用户是自媒体创作者和文案从业者。核心功能:AI 写文章、智能改写、多平台适配排版、一键生成配图。竞品有 Jasper 和 Copy.ai。',
description: '创意营销场景:设计 AI 产品的上市推广方案。',
instructions: '请设计"文思"产品的上市营销推广方案。要求:\n1. 核心卖点提炼(3 个以内,一句话一个)\n2. 目标用户画像(至少 2 类)\n3. 定价策略建议\n4. 冷启动推广渠道和内容策略\n5. 病毒传播机制设计\n6. 写出一个社交媒体推广文案示例(140 字以内)',
difficulty: 'INTERMEDIATE',
category: 'creative',
expectedCriteria: JSON.stringify([
{ name: '卖点提炼', maxScore: 20, description: '卖点是否精准差异化' },
{ name: '用户洞察', maxScore: 20, description: '用户画像是否真实有深度' },
{ name: '推广策略', maxScore: 30, description: '冷启动和病毒传播机制是否有创意可执行' },
{ name: '文案示例', maxScore: 30, description: '文案是否有感染力、符合社交媒体调性' },
]),
hint: '考虑用"AI 写作合伙人"的概念替代"AI 工具"——后者让创作者感到威胁,前者让他们感到被赋能。可以设计"AI 写初稿,你写爆款"的传播概念。',
sortOrder: 8,
},
// === 行业专题练习(2026 年真实场景)===
{
title: '金融风控:AI 反欺诈规则设计',
scenario: '你是一家银行的风控分析师。2026 年第一季度,该行信用卡交易欺诈损失同比增长 37%,主要来自新型的"合成身份欺诈"——犯罪分子用真实信息碎片拼凑虚假身份开户。管理层要求你用 AI 设计一套实时反欺诈规则引擎。',
description: '金融风控场景:设计基于 AI 的实时交易反欺诈规则。',
instructions: '请设计一套 AI 反欺诈规则引擎方案。要求:\n1. 识别至少 4 个合成身份欺诈的行为特征(如设备指纹、交易模式、社交图谱等)\n2. 设计多层规则架构(规则层 + 模型层 + 决策层)\n3. 描述实时风控流程(交易发起 → 特征提取 → 模型评分 → 决策输出)\n4. 提出模型训练数据的特征工程方案\n5. 考虑误杀率(False Positive)控制策略——拒绝正常交易会影响客户体验和收入',
difficulty: 'ADVANCED',
category: 'analysis',
expectedCriteria: JSON.stringify([
{ name: '欺诈特征识别', maxScore: 20, description: '是否准确识别了合成身份欺诈的关键行为特征' },
{ name: '架构设计', maxScore: 25, description: '多层规则架构是否合理完整' },
{ name: '实时流程', maxScore: 25, description: '实时风控流程描述是否清晰可行' },
{ name: '误杀控制', maxScore: 30, description: '误杀率控制策略是否具体有效' },
]),
hint: '合成身份欺诈的特征包括:设备指纹异常(虚拟机/模拟器)、社交关系稀疏(0-3 个联系人)、交易金额模式异常(小额测试→大额盗刷)。考虑使用 Graph Neural Network 检测社交图谱异常。2025 年美联储报告显示合成身份欺诈占信用卡欺诈的 20%。',
sortOrder: 9,
},
{
title: '医疗场景:AI 临床文书生成',
scenario: '你在一家三甲医院的信息化部门工作。2026 年国家卫健委要求三级医院电子病历评级达到 5 级以上。医生普遍抱怨录入病历占用大量时间——每位门诊医生日均花费 2.5 小时写病历。医院决定引入 AI 临床文书系统,将医患对话自动转写成结构化病历。',
description: '医疗信息化场景:设计 AI 辅助临床文书系统。',
instructions: '请设计 AI 临床文书系统的 Prompt 方案。要求:\n1. 设计一个 System Prompt,将口语化医患对话转写为标准 SOAPSubjective, Objective, Assessment, Plan)格式病历\n2. 给出一个示例对话(医生问诊高血压患者)和转写结果\n3. 说明如何处理关键问题:医学术语标准化、隐私信息脱敏、非结构化口语的消歧\n4. 设计质控机制——AI 生成的病历如何由医生审核确认',
difficulty: 'ADVANCED',
category: 'general',
expectedCriteria: JSON.stringify([
{ name: 'Prompt 设计', maxScore: 25, description: 'System Prompt 是否完整覆盖 SOAP 四部分' },
{ name: '示例质量', maxScore: 25, description: '示例对话和转写结果是否专业准确' },
{ name: '技术难点', maxScore: 25, description: '术语标准化、脱敏、消歧方案是否可行' },
{ name: '质控机制', maxScore: 25, description: '审核确认流程是否合理合规' },
]),
hint: '参考 10-20-70 AI 落地法则:10% 算法、20% 技术工程、70% 组织变革。医生的接受度是关键瓶颈。建议设计"AI 起草 + 医生审核"模式,而非全自动。病历要用 SNOMED CT 或 ICD-11 标准化术语。',
sortOrder: 10,
},
{
title: '电商推荐:AI 个性化引擎设计',
scenario: '你是一家年 GMV 50 亿元的跨境电商平台的数据科学负责人。当前推荐系统基于协同过滤,新品 7 日曝光率仅 12%,冷启动问题严重。2026 年 70% 的电商高管将 AI 个性化列为首要投资方向。CEO 要求你设计新一代 AI 推荐引擎。',
description: '电商推荐场景:设计基于多模态 AI 的个性化推荐引擎。',
instructions: '请设计新一代 AI 推荐引擎方案。要求:\n1. 架构设计:数据层 → 特征工程 → 召回 → 排序 → 重排 → 混排\n2. 解决冷启动问题的具体方案(新品、新用户、新场景)\n3. 多模态特征融合方案(文本描述、商品图片、用户行为序列)\n4. 实时个性化策略(基于当前 Session 行为的实时调整)\n5. A/B 测试评估指标体系',
difficulty: 'ADVANCED',
category: 'analysis',
expectedCriteria: JSON.stringify([
{ name: '架构完整性', maxScore: 20, description: '各层组件和功能是否完整' },
{ name: '冷启动方案', maxScore: 30, description: '冷启动方案是否具体可行' },
{ name: '多模态融合', maxScore: 25, description: '多模态特征融合方案是否技术合理' },
{ name: '评估体系', maxScore: 25, description: 'A/B 测试指标体系是否科学完整' },
]),
hint: '冷启动可以用内容特征(CLIP 提取图像向量)+ 知识图谱(品类关联)+ Exploration 策略(epsilon-greedy 或 Thompson Sampling)。多模态融合推荐使用两阶段:先 image embedding 聚类,再 user behavior sequence 排序。',
sortOrder: 11,
},
{
title: 'HR 招聘:AI 筛选偏见审计',
scenario: '你是一家千人规模科技公司的 HR 技术负责人。2025 年公司引入 AI 简历筛选系统后,算法岗女性候选人进入面试的比例从 38% 骤降到 12%。初步排查发现,模型因为历史数据中男性工程师占比 85%,学到了"男性更合适"的偏见。现在法律部门要求你出具审计报告并修复问题。',
description: 'HR 与 AI 伦理场景:识别并修复 AI 招聘中的偏见问题。',
instructions: '作为 AI 伦理审计负责人,请完成以下任务:\n1. 分析 AI 简历筛选系统产生性别偏见的根因(至少 3 个)\n2. 设计偏见量化评估方法(用什么指标衡量偏见程度)\n3. 提出至少 4 个具体的修复方案\n4. 设计持续监控机制——上线后如何确保偏见不再复发\n5. 起草一份给管理层的摘要说明(200 字以内),解释问题、影响和解决方案',
difficulty: 'INTERMEDIATE',
category: 'general',
expectedCriteria: JSON.stringify([
{ name: '根因分析', maxScore: 20, description: '偏见根因分析是否全面深入' },
{ name: '量化评估', maxScore: 25, description: '偏见量化指标是否科学合理' },
{ name: '修复方案', maxScore: 30, description: '修复方案是否具体、可落地' },
{ name: '沟通表达', maxScore: 25, description: '管理层摘要是否清晰有说服力' },
]),
hint: '偏见来源:历史数据偏差(历史 hire 数据中男女比例失衡)、特征选择偏差(gap in employment 对女性不利)、标注偏差(面试官主观偏好)。修复方法:对抗性去偏(Adversarial Debiasing)、重采样、公平性约束(Demographic Parity / Equal Opportunity)。用 "Demographic Parity Difference" < 0.1 作为公平性阈值。',
sortOrder: 12,
},
{
title: '教育科技:AI 一对一辅导 Prompt 设计',
scenario: '你是一家 EdTech 公司的 AI 教育产品经理。2026 年 Digital Education Council 报告显示 86% 的大学生使用 AI 辅助学习,但 80% 的大学仍未制定 AI 使用政策。你的公司要推出一款 AI 一对一辅导系统,能根据学生水平和学习风格自适应调整教学策略,定位为"大学官方合作的合规 AI 辅导工具"。',
description: '教育科技场景:设计自适应 AI 辅导的系统 Prompt。',
instructions: '请为一个 AI 数学辅导系统设计核心 Prompt 和工作流程。要求:\n1. 设计 System Prompt 使 AI 能扮演耐心的高中数学老师\n2. 实现自适应策略:根据学生水平(基础/中等/拔高)调整讲解深度和例题难度\n3. 设计苏格拉底式教学法——不直接给答案,而是通过提问引导学生思考\n4. 包含错误分析机制——当学生答错时,识别错误类型(概念混淆/计算失误/审题不清)并针对性讲解\n5. 给出一个教学对话示例(主题:一元二次方程)',
difficulty: 'INTERMEDIATE',
category: 'creative',
expectedCriteria: JSON.stringify([
{ name: 'Prompt 设计', maxScore: 20, description: 'System Prompt 是否体现了耐心老师的角色' },
{ name: '自适应策略', maxScore: 25, description: '自适应策略是否清晰、可执行' },
{ name: '教学方法', maxScore: 30, description: '苏格拉底教学法和错误分析机制是否有效' },
{ name: '示例质量', maxScore: 25, description: '教学对话示例是否自然且体现设计要点' },
]),
hint: '可以用"Level 1/2/3"三级自适应:Level 1 多步骤引导、具象化类比;Level 2 核心概念讲解 + 中等难度;Level 3 给出思考方向后让学习者自己推导。错误分析用 Chain-of-Thought 识别类型,再匹配对应的教学策略。参考 Khan Academy 的 "Mastery Learning" 理念。',
sortOrder: 13,
},
{
title: '法律合规:GDPR 与 AI 法案合规审计',
scenario: '你的公司开发了一款 AI 面试助手,用于自动化筛选候选人并生成面试报告。2026 年欧盟 AI 法案已全面生效,你的产品被分类为"高风险 AI 系统"。法务部门要求你出具合规差距分析报告,确保产品满足 GDPR 和 AI 法案要求,否则将面临最高全球营收 6% 的罚款。',
description: '法律合规场景:AI 产品合规与数据保护审计。',
instructions: '请对 AI 面试助手进行合规差距分析。要求:\n1. 识别高风险 AI 系统的合规要求(透明性、人类监督、准确性、公平性、数据治理)\n2. 设计合规检查清单(至少 8 项)\n3. 给出数据处理方案:候选人数据如何收集、存储、保留、删除\n4. 设计"被遗忘权"实现方案——候选人要求删除数据时的技术流程\n5. 设计透明度声明模板——向候选人说明 AI 如何参与招聘决策',
difficulty: 'INTERMEDIATE',
category: 'general',
expectedCriteria: JSON.stringify([
{ name: '合规要求识别', maxScore: 25, description: '是否准确识别了高风险 AI 系统关键合规要求' },
{ name: '检查清单', maxScore: 25, description: '检查清单是否全面可操作' },
{ name: '数据处理', maxScore: 25, description: '数据治理方案是否符合 GDPR 原则' },
{ name: '透明度', maxScore: 25, description: '透明度声明是否清晰、对候选人友好' },
]),
hint: 'AI 法案高风险要求包括:建立风险管理系统、使用代表性训练数据、记录自动日志、确保人类审查、达到准确性和公平性标准。GDPR 第 22 条赋予公民"不受自动化决策约束"的权利。被遗忘权技术实现:级联删除 + 匿名化 + 模型重训(machine unlearning 尚不成熟,可用排除列表替代)。',
sortOrder: 14,
},
{
title: '产品管理:AI 驱动的需求优先级排序',
scenario: '你是一家 SaaS 平台的产品经理。季度规划会议上,你收集到 15 个待开发功能需求,但研发团队只有 50 人/周产能。销售部要求做大客户定制功能,客服部要求优化工单系统,用户反馈要求移动端适配。CEO 要求用数据驱动的方法精准排定优先级。',
description: '产品管理场景:用 AI 辅助数据分析进行需求优先级排序。',
instructions: '请设计方案,用数据驱动的方法给 15 个需求排定优先级。要求:\n1. 设计量化评分模型(至少 4 个维度:用户价值、商业价值、实现成本、战略对齐)\n2. 说明每个维度的数据和量化方法\n3. 设计 AI 辅助分析流程——如何利用 AI 分析用户反馈数据提取关键需求\n4. 给出 RICE 评分框架的具体实现(Reach, Impact, Confidence, Effort\n5. 讨论如何处理"高价值但高风险"的需求决策',
difficulty: 'INTERMEDIATE',
category: 'analysis',
expectedCriteria: JSON.stringify([
{ name: '评分模型', maxScore: 25, description: '量化评分模型维度是否全面合理' },
{ name: '数据量化', maxScore: 25, description: '各维度的数据来源和量化方法是否可行' },
{ name: 'AI 辅助', maxScore: 25, description: 'AI 分析用户反馈的流程是否有效' },
{ name: '决策讨论', maxScore: 25, description: '高风险需求的处理策略是否合理' },
]),
hint: 'RICE 是成熟的优先级框架:Reach(影响用户数/季度)、Impact(转化率提升估算)、Confidence(数据支撑度 %)、Effort(人周)。AI 辅助:用 LLM 对用户反馈进行主题聚类 + 情感分析 + 需求提取,然后映射到功能需求池。高价值高风险用"最小可行实验"(MVE)验证后再承诺。',
sortOrder: 15,
},
{
title: '运营增长:AI 驱动用户留存策略',
scenario: '你的社交电商 APP 在 2025 年底获客成本(CAC)涨到 128 元/人,但 30 日留存率只有 18%。投资人要求优化留存而非盲目获客。你决定设计一套 AI 驱动的用户留存系统,在用户可能流失前进行精准干预。',
description: '运营增长场景:设计 AI 驱动的用户流失预警与留存方案。',
instructions: '请设计 AI 用户留存系统方案。要求:\n1. 构建用户流失预测模型——识别至少 5 个关键预警信号(行为特征)\n2. 设计分层干预策略(轻度风险→推送优惠券,中度风险→人工回访,重度风险→专属福利)\n3. 设计个性化留存动作——根据用户画像匹配不同的激励方式\n4. 设计 uplift 模型衡量干预的增量效果(反事实推断)\n5. 给出 A/B 测试框架验证留存策略有效性',
difficulty: 'ADVANCED',
category: 'analysis',
expectedCriteria: JSON.stringify([
{ name: '预警信号', maxScore: 20, description: '流失预警信号是否全面、可量化' },
{ name: '干预策略', maxScore: 30, description: '分层干预策略是否合理、可执行' },
{ name: '个性化', maxScore: 25, description: '个性化留存方案是否精准有效' },
{ name: '实验验证', maxScore: 25, description: 'uplift 模型和 A/B 测试框架是否科学' },
]),
hint: '流失预警信号:登录频率下降(间隔>5天)、核心行为减少(浏览商品数下降 50%)、负反馈增加(投诉/退单)、社交互动减少(点赞评论归零)、竞品活跃度。Uplift Model 用 Two-Model 方法或 Class Transformation Method。用 "Incremental Lift" 而非「转化率提升」衡量效果。',
sortOrder: 16,
},
{
title: '供应链管理:AI 需求预测与库存优化',
scenario: '你是一家跨境电商公司的供应链总监。公司经营 5000+ SKU,2025 年因库存管理不善造成 2300 万元滞销损失,同时有 140 个爆款 SKU 出现断货。2026 年全球供应链波动加剧,管理层要求引入 AI 驱动的需求预测系统,将库存周转率提升 30%。',
description: '供应链场景:构建 AI 驱动的需求预测与智能补货系统。',
instructions: '请设计 AI 需求预测系统方案。要求:\n1. 预测模型方案:时间序列特征工程策略(季节性、趋势、促销事件、外部因素)\n2. 多层级预测架构:SKU 级 → 品类级 → 仓库级,如何保证一致性\n3. 智能补货策略:动态安全库存计算(考虑交货周期波动)\n4. 滞销品处理机制:识别滞销品并给出清仓建议(降价幅度、促销渠道)\n5. 数据质量保障方案:如何处理历史数据中的缺失值和异常值',
difficulty: 'ADVANCED',
category: 'analysis',
expectedCriteria: JSON.stringify([
{ name: '特征工程', maxScore: 20, description: '时间序列特征是否全面合理' },
{ name: '层级架构', maxScore: 25, description: '多层级预测一致性方案是否可行' },
{ name: '补货策略', maxScore: 30, description: '动态安全库存计算和补货策略是否合理' },
{ name: '数据质量', maxScore: 25, description: '数据质量保障方案是否具体有效' },
]),
hint: '多层级一致性用 "Top-Down Reconciliation"(按历史比例拆分)或 "Middle-Out" 方法。安全库存公式:SS = Z × σ_d × √L(Z=服务水平系数,σ_d=需求标准差,L=交货周期)。用 Prophet 或 DeepAR 做基础预测。缺失值处理:时间点用插值,序列头尾用回填。滞销品按 "库存天数 > 90 天且周销量 < 5" 定义。',
sortOrder: 17,
},
{
title: 'B2B 销售:AI 销售线索评分模型',
scenario: '你是一家企业 SaaS 公司的市场总监。市场部每月从官网、展会、广告等渠道获取 3000+ 销售线索,但销售团队只有 15 人,只能跟进其中 30%。目前线索分配靠销售主管手动判断,优质线索跟进不及时,低质线索浪费销售精力。公司要求将线索到签约的转化率提升一倍。',
description: 'B2B 销售场景:用 AI 构建销售线索评分与自动分配系统。',
instructions: '请设计 AI 销售线索评分与分配系统。要求:\n1. 线索评分模型:定义至少 6 个评分特征(如公司规模、决策者级别、行为意图等)\n2. 评分算法:设计打分规则(加权评分 / 逻辑回归 / 机器学习模型)\n3. 线索分配规则:根据评分和销售专长自动分配\n4. 反馈闭环:如何利用历史转化数据优化评分模型\n5. 设计一个评分报表 Dashboard 方案——销售主管看到什么',
difficulty: 'INTERMEDIATE',
category: 'analysis',
expectedCriteria: JSON.stringify([
{ name: '评分特征', maxScore: 25, description: '评分特征是否全面反映购买意向' },
{ name: '评分算法', maxScore: 25, description: '评分算法是否合理可解释' },
{ name: '分配机制', maxScore: 25, description: '线索分配规则是否合理、公平' },
{ name: '反馈闭环', maxScore: 25, description: '模型优化反馈闭环是否完整' },
]),
hint: 'BANT 模型(Budget, Authority, Need, Timeline)是经典线索评估框架。行为意图信号:下载白皮书、访问定价页、参与 Demo 预约、打开营销邮件≥3 次。评分可用 Logistic Regression(可解释性好),输出 0-100 分。80 分以上自动分配给 Top Sales60-80 分分配给 Inside Sales60 分以下进入培育流程。',
sortOrder: 18,
},
{
title: '内容创作:AI 视频脚本与数字人方案',
scenario: '你是一家 MCN 机构的内容策略总监。2026 年短视频流量红利见顶,纯人工创作产能不足——一个 3 人团队每周最多产出 5 条视频。公司决定引入 AI 数字人主播 + AI 脚本生成,在保持内容质量的前提下将产能提升 5 倍。老板让你设计完整方案。',
description: '内容创作场景:设计 AI 数字人主播生产流程与脚本 Prompt。',
instructions: '请设计 AI 数字人内容生产方案。要求:\n1. 设计 AI 脚本生成 Prompt——生成 60 秒口播短视频脚本(主题:AI 工具推荐),包含爆款开头、核心内容、CTA\n2. 描述从脚本到视频的完整生产流程\n3. 如何保证数字人内容的"人感"——避免 AI 味太重\n4. 内容质量控制机制——如何确保品牌调性一致\n5. A/B 测试策略——如何用数据优化 AI 内容的完播率和转化率',
difficulty: 'INTERMEDIATE',
category: 'creative',
expectedCriteria: JSON.stringify([
{ name: 'Prompt 设计', maxScore: 25, description: '脚本 Prompt 能否生成高质量口播脚本' },
{ name: '生产流程', maxScore: 20, description: '生产流程是否完整可执行' },
{ name: '人感策略', maxScore: 30, description: '避免 AI 味的策略是否有效' },
{ name: '优化机制', maxScore: 25, description: 'A/B 测试和质量控制机制是否合理' },
]),
hint: '短视频爆款开头公式:前 3 秒制造冲突/悬念/共鸣。"人感"策略:加入语气词(嗯、其实、我跟你说)、节奏变化(快慢交替)、真实的微表情(数字人加入眨眼和头部微动)。数字人主播选择要考虑目标受众偏好——2026 年数据显示 35 岁以下用户更喜欢二次元风格数字人。',
sortOrder: 19,
},
{
title: 'AI 安全:企业 Shadow AI 治理方案',
scenario: '你是一家金融科技公司的 CISO(首席信息安全官)。2026 年 Microsoft 报告显示 75% 的员工在使用未授权的 AI 工具处理工作,其中 48% 将公司数据粘贴到公共 AI 平台。上个月,一名员工将包含客户 PII 数据的代码片段粘贴到公共 ChatGPT,导致合规事件。CEO 要求你制定 Shadow AI 治理方案。',
description: 'AI 安全与治理场景:企业 Shadow AI(影子 AI)风险管控方案。',
instructions: '请设计企业 Shadow AI 治理方案。要求:\n1. 风险识别:列出至少 5 个 Shadow AI 使用场景及风险等级\n2. 技术管控方案:如何检测和阻止敏感数据流向未经授权的 AI 平台\n3. 合规方案:设计企业 AI 使用政策,明确哪些数据可以/不可以输入 AI\n4. 员工培训策略:如何让员工理解 AI 安全风险(而非简单地封禁)\n5. 替代方案:如何提供"安全的企业 AI 工具"让员工不需要用外面的 AI',
difficulty: 'INTERMEDIATE',
category: 'analysis',
expectedCriteria: JSON.stringify([
{ name: '风险识别', maxScore: 25, description: 'Shadow AI 风险场景是否识别全面' },
{ name: '技术管控', maxScore: 25, description: '技术管控方案是否有效可落地' },
{ name: '合规政策', maxScore: 25, description: 'AI 使用政策是否明确合理' },
{ name: '替代方案', maxScore: 25, description: '企业安全 AI 替代方案是否可行' },
]),
hint: '检测方案:DLP(数据防泄漏)监控出站流量中的 API 调用特征(已知 AI 平台域名+IP)、浏览器扩展检测、网络代理日志分析。Block 方案:在网关层阻止对未授权 AI 域名的访问。替代方案的核心是数据隔离——企业内部部署的 AI 不会将数据用于模型训练。参考 Cisco 2026 数据隐私基准研究。',
sortOrder: 20,
},
];
async function main() {
console.log('Seeding practice questions...');
await prisma.practiceQuestion.deleteMany();
for (let i = 0; i < practices.length; i++) {
const p = practices[i];
await prisma.practiceQuestion.create({
data: { ...p, sortOrder: i + 1 },
});
console.log(`${p.title}`);
}
console.log(`Seeded ${practices.length} practice questions.`);
}
main()
.catch(e => { console.error(e); process.exit(1); })
.finally(async () => { await prisma.$disconnect(); });
Vendored Regular → Executable
View File
Regular → Executable
View File
Regular → Executable
View File
Regular → Executable
View File
Regular → Executable
+22 -20
View File
@@ -1,5 +1,7 @@
import { Module } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config';
import { ThrottlerModule, ThrottlerGuard } from '@nestjs/throttler';
import { APP_GUARD } from '@nestjs/core';
import { PrismaModule } from './prisma/prisma.module';
import { AuthModule } from './modules/auth/auth.module';
import { UsersModule } from './modules/users/users.module';
@@ -21,31 +23,31 @@ import { EnterpriseModule } from './modules/enterprise/enterprise.module';
import { NotificationModule } from './modules/notifications/notification.module';
import { LearningModule } from './modules/learning/learning.module';
import { SkillsModule } from './modules/skills/skills.module';
import { AiAssistantModule } from './modules/ai-assistant/ai-assistant.module';
import { PracticesModule } from './modules/practices/practices.module';
import { RedisThrottlerStorage } from './common/redis-throttler-storage';
@Module({
imports: [
ConfigModule.forRoot({ isGlobal: true }),
ThrottlerModule.forRoot({
throttlers: [
{ name: 'short', ttl: 60000, limit: 60 },
{ name: 'medium', ttl: 300000, limit: 200 },
],
storage: new RedisThrottlerStorage(),
}),
PrismaModule,
AuthModule,
UsersModule,
CoursesModule,
ContentsModule,
PromptsModule,
ToolsModule,
SandboxModule,
OrdersModule,
AdminModule,
PaymentModule,
CategoriesModule,
DashboardModule,
SearchModule,
ModelsModule,
UploadModule,
CommunityModule,
EnterpriseModule,
NotificationModule,
LearningModule,
SkillsModule,
AuthModule, UsersModule, CoursesModule, ContentsModule,
PromptsModule, ToolsModule, SandboxModule, OrdersModule,
AdminModule, PaymentModule, CategoriesModule, DashboardModule,
SearchModule, ModelsModule, UploadModule, CommunityModule,
EnterpriseModule, NotificationModule, LearningModule, SkillsModule,
AiAssistantModule,
PracticesModule,
],
providers: [
{ provide: APP_GUARD, useClass: ThrottlerGuard },
],
})
export class AppModule {}
View File
Regular → Executable
View File
View File
@@ -0,0 +1,35 @@
import { ThrottlerStorage } from '@nestjs/throttler';
import Redis from 'ioredis';
export class RedisThrottlerStorage implements ThrottlerStorage {
private redis: Redis;
constructor() {
this.redis = new Redis({
host: process.env.REDIS_HOST || 'localhost',
port: parseInt(process.env.REDIS_PORT || '6379', 10),
password: process.env.REDIS_PASSWORD || undefined,
keyPrefix: 'throttle:',
});
}
async increment(key: string, ttl: number, limit: number, blockDuration: number, throttlerName: string): Promise<{
totalHits: number;
timeToExpire: number;
isBlocked: boolean;
timeToBlockExpire: number;
}> {
const redisKey = `throttle:${throttlerName}:${key}`;
const total = await this.redis.incr(redisKey);
if (total === 1) {
await this.redis.pexpire(redisKey, ttl);
}
const ttlRemaining = await this.redis.pttl(redisKey);
return {
totalHits: total,
timeToExpire: Math.max(0, Math.ceil(ttlRemaining / 1000)),
isBlocked: total > limit,
timeToBlockExpire: total > limit ? Math.max(0, Math.ceil(ttlRemaining / 1000)) : 0,
};
}
}
Regular → Executable
+32 -2
View File
@@ -5,6 +5,7 @@ import { NestExpressApplication } from '@nestjs/platform-express';
import { join } from 'path';
import helmet from 'helmet';
import * as compression from 'compression';
import * as cookieParser from 'cookie-parser';
import { AppModule } from './app.module';
import { GlobalExceptionFilter, LoggingInterceptor } from './common';
@@ -15,14 +16,36 @@ async function bootstrap() {
app.use(helmet({
crossOriginResourcePolicy: { policy: 'cross-origin' },
contentSecurityPolicy: false,
contentSecurityPolicy: {
directives: {
defaultSrc: ["'self'"],
scriptSrc: ["'self'", "'unsafe-inline'", "'unsafe-eval'", 'https:'],
styleSrc: ["'self'", "'unsafe-inline'", 'https:'],
imgSrc: ["'self'", 'data:', 'blob:', 'https:'],
connectSrc: ["'self'", 'https:'],
fontSrc: ["'self'", 'https:'],
objectSrc: ["'none'"],
frameSrc: ["'self'"],
},
},
}));
app.use(cookieParser());
app.use(compression());
app.useStaticAssets(join(process.cwd(), 'uploads'), { prefix: '/uploads' });
app.setGlobalPrefix('api/v1');
const allowedOrigins = process.env.CORS_ORIGINS
? process.env.CORS_ORIGINS.split(',')
: ['https://yuzhiran.com', 'https://www.yuzhiran.com', 'https://www.yuzhiran.com.cn'];
app.enableCors({
origin: true,
origin: (origin, callback) => {
if (!origin || allowedOrigins.includes(origin)) {
callback(null, true);
} else {
callback(null, false);
}
},
credentials: true,
methods: ['GET', 'POST', 'PUT', 'DELETE', 'PATCH'],
allowedHeaders: ['Content-Type', 'Authorization'],
@@ -49,4 +72,11 @@ async function bootstrap() {
console.log(`API 文档: http://localhost:${port}/api/docs`);
}
process.on('unhandledRejection', (reason) => {
console.error('Unhandled Rejection:', reason);
});
process.on('uncaughtException', (error) => {
console.error('Uncaught Exception:', error);
});
bootstrap();
+369 -7
View File
@@ -1,4 +1,4 @@
import { Controller, Post, Get, Put, Body, UseGuards, Req, Param, HttpException, HttpStatus } from '@nestjs/common';
import { Controller, Post, Get, Put, Delete, Body, UseGuards, Req, Param, Query, HttpException, HttpStatus, ParseIntPipe } from '@nestjs/common';
import { ApiTags, ApiBearerAuth } from '@nestjs/swagger';
import { AuthGuard } from '@nestjs/passport';
import { AdminService } from './admin.service';
@@ -7,6 +7,7 @@ import { CoursesService } from '../courses/courses.service';
import { PrismaService } from '../../prisma/prisma.service';
import { AIGatewayService } from '../ai/ai-gateway.service';
import { AdminAiAssistantService } from './ai-assistant.service';
import { GatewayPayService } from '../payment/gateway-pay.service';
@ApiTags('管理后台')
@Controller('admin')
@@ -17,6 +18,7 @@ export class AdminController {
private prisma: PrismaService,
private aiGateway: AIGatewayService,
private aiAssistant: AdminAiAssistantService,
private gatewayPay: GatewayPayService,
) {}
@Post('login')
@@ -34,13 +36,124 @@ export class AdminController {
@Get('orders')
@UseGuards(AuthGuard('jwt'), AdminGuard)
@ApiBearerAuth()
async orders(@Req() req: any) {
const items = await this.prisma.order.findMany({
orderBy: { createdAt: 'desc' },
take: 100,
include: { user: { select: { id: true, nickname: true, phone: true } } },
async orders(
@Query('page') page?: string,
@Query('pageSize') pageSize?: string,
@Query('search') search?: string,
@Query('status') status?: string,
@Query('payChannel') payChannel?: string,
@Query('planType') planType?: string,
) {
const p = Math.max(1, Number(page) || 1);
const ps = Math.min(100, Math.max(1, Number(pageSize) || 20));
const skip = (p - 1) * ps;
const where: any = {};
if (status) where.status = status;
if (payChannel) where.payChannel = payChannel;
if (planType) where.planType = planType;
if (search) {
where.OR = [
{ orderNo: { contains: search } },
{ user: { nickname: { contains: search } } },
{ user: { phone: { contains: search } } },
];
}
const [items, total] = await Promise.all([
this.prisma.order.findMany({
where,
orderBy: { createdAt: 'desc' },
skip,
take: ps,
include: { user: { select: { id: true, nickname: true, phone: true } } },
}),
this.prisma.order.count({ where }),
]);
return { items, total, page: p, pageSize: ps };
}
@Get('orders/:orderNo')
@UseGuards(AuthGuard('jwt'), AdminGuard)
@ApiBearerAuth()
async orderDetail(@Param('orderNo') orderNo: string) {
const order = await this.prisma.order.findUnique({
where: { orderNo },
include: { user: { select: { id: true, nickname: true, phone: true, email: true, memberPlan: true, memberExpire: true } } },
});
return { items };
if (!order) throw new HttpException('订单不存在', HttpStatus.NOT_FOUND);
return order;
}
@Post('orders/:orderNo/refund')
@UseGuards(AuthGuard('jwt'), AdminGuard)
@ApiBearerAuth()
async orderRefund(@Param('orderNo') orderNo: string, @Body() body: { amount?: number; reason?: string }) {
const order = await this.prisma.order.findUnique({ where: { orderNo } });
if (!order) throw new HttpException('订单不存在', HttpStatus.NOT_FOUND);
if (order.status !== 'PAID') throw new HttpException('订单未支付,无法退款', HttpStatus.BAD_REQUEST);
return this.gatewayPay.refund(orderNo, body.amount || order.amount, body.reason);
}
@Post('orders/:orderNo/mark-paid')
@UseGuards(AuthGuard('jwt'), AdminGuard)
@ApiBearerAuth()
async markOrderPaid(@Param('orderNo') orderNo: string) {
const order = await this.prisma.order.findUnique({ where: { orderNo } });
if (!order) throw new HttpException('订单不存在', HttpStatus.NOT_FOUND);
if (order.status !== 'PENDING') throw new HttpException('只能标记待支付订单为已支付', HttpStatus.BAD_REQUEST);
await this.prisma.order.update({
where: { id: order.id },
data: { status: 'PAID', paidAt: new Date(), transactionId: `MANUAL_${Date.now()}` },
});
// 激活订阅
const mockOrder = { planType: order.planType, amount: order.amount };
if (mockOrder.planType === 'MONTHLY' || mockOrder.planType === 'YEARLY') {
const durationDays = mockOrder.planType === 'MONTHLY' ? 30 : 365;
const now = new Date();
let endDate: Date;
const existingSub = await this.prisma.subscription.findFirst({
where: { userId: order.userId, status: 'ACTIVE', endDate: { gt: now } },
});
if (existingSub) {
endDate = new Date(existingSub.endDate.getTime() + durationDays * 24 * 60 * 60 * 1000);
await this.prisma.subscription.update({ where: { id: existingSub.id }, data: { endDate } });
} else {
endDate = new Date(now);
endDate.setDate(endDate.getDate() + durationDays);
await this.prisma.subscription.create({
data: { userId: order.userId, plan: order.planType as any, startDate: now, endDate, status: 'ACTIVE' },
});
}
await this.prisma.user.update({
where: { id: order.userId },
data: { memberPlan: order.planType as any, memberExpire: endDate },
});
}
return { ok: true, message: '订单已标记为已支付' };
}
@Get('orders/:orderNo/query')
@UseGuards(AuthGuard('jwt'), AdminGuard)
@ApiBearerAuth()
async queryOrderPayment(@Param('orderNo') orderNo: string) {
const order = await this.prisma.order.findUnique({ where: { orderNo } });
if (!order) throw new HttpException('订单不存在', HttpStatus.NOT_FOUND);
if (order.gatewayOrderId && !order.gatewayOrderId.startsWith('mock_')) {
return this.gatewayPay.queryOrder(order.gatewayOrderId);
}
return {
outTradeNo: orderNo,
localStatus: order.status,
amount: order.amount,
planType: order.planType,
};
}
@Put('courses/:id/chapters')
@@ -119,4 +232,253 @@ export class AdminController {
return { reply: result.summary, result };
}
}
// --- Practice Questions Admin ---
@Get('practices')
@UseGuards(AuthGuard('jwt'), AdminGuard)
@ApiBearerAuth()
async adminPractices(@Query('page') page?: string, @Query('pageSize') pageSize?: string, @Query('search') search?: string) {
const p = Math.max(1, Number(page) || 1);
const ps = Math.min(100, Math.max(1, Number(pageSize) || 20));
const where: any = {};
if (search) where.title = { contains: search };
const [items, total] = await Promise.all([
this.prisma.practiceQuestion.findMany({ where, orderBy: { sortOrder: 'asc' }, skip: (p - 1) * ps, take: ps }),
this.prisma.practiceQuestion.count({ where }),
]);
return { items, total, page: p, pageSize: ps };
}
@Get('practices/:id')
@UseGuards(AuthGuard('jwt'), AdminGuard)
@ApiBearerAuth()
async adminPractice(@Param('id', ParseIntPipe) id: number) {
const item = await this.prisma.practiceQuestion.findUnique({ where: { id } });
if (!item) throw new HttpException('练习题目不存在', HttpStatus.NOT_FOUND);
return item;
}
@Post('practices')
@UseGuards(AuthGuard('jwt'), AdminGuard)
@ApiBearerAuth()
async createPractice(@Body() body: any) {
const item = await this.prisma.practiceQuestion.create({ data: body });
return item;
}
@Put('practices/:id')
@UseGuards(AuthGuard('jwt'), AdminGuard)
@ApiBearerAuth()
async updatePractice(@Param('id', ParseIntPipe) id: number, @Body() body: any) {
const { id: _id, ...data } = body;
const item = await this.prisma.practiceQuestion.update({ where: { id }, data });
return item;
}
@Delete('practices/:id')
@UseGuards(AuthGuard('jwt'), AdminGuard)
@ApiBearerAuth()
async deletePractice(@Param('id', ParseIntPipe) id: number) {
await this.prisma.practiceQuestion.delete({ where: { id } });
return { ok: true };
}
// --- Skills Admin ---
@Get('skills')
@UseGuards(AuthGuard('jwt'), AdminGuard)
@ApiBearerAuth()
async adminSkills(@Query('page') page?: string, @Query('pageSize') pageSize?: string) {
const p = Math.max(1, Number(page) || 1);
const ps = Math.min(100, Math.max(1, Number(pageSize) || 20));
const [items, total] = await Promise.all([
this.prisma.skill.findMany({ orderBy: { sortOrder: 'asc' }, skip: (p - 1) * ps, take: ps }),
this.prisma.skill.count(),
]);
return { items, total, page: p, pageSize: ps };
}
@Get('skills/:id')
@UseGuards(AuthGuard('jwt'), AdminGuard)
@ApiBearerAuth()
async adminSkill(@Param('id') id: string) {
const item = await this.prisma.skill.findUnique({ where: { id } });
if (!item) throw new HttpException('技能不存在', HttpStatus.NOT_FOUND);
return item;
}
@Put('skills/:id')
@UseGuards(AuthGuard('jwt'), AdminGuard)
@ApiBearerAuth()
async updateSkill(@Param('id') id: string, @Body() body: any) {
const { id: _id, purchasedBy, ...data } = body;
const item = await this.prisma.skill.update({ where: { id }, data });
return item;
}
// --- Circles Admin ---
@Get('circles')
@UseGuards(AuthGuard('jwt'), AdminGuard)
@ApiBearerAuth()
async adminCircles(@Query('page') page?: string, @Query('pageSize') pageSize?: string) {
const p = Math.max(1, Number(page) || 1);
const ps = Math.min(100, Math.max(1, Number(pageSize) || 20));
const [items, total] = await Promise.all([
this.prisma.circle.findMany({ orderBy: { createdAt: 'desc' }, skip: (p - 1) * ps, take: ps, include: { creator: { select: { id: true, nickname: true } }, _count: { select: { members: true, posts: true } } } }),
this.prisma.circle.count(),
]);
return { items, total, page: p, pageSize: ps };
}
@Delete('circles/:id')
@UseGuards(AuthGuard('jwt'), AdminGuard)
@ApiBearerAuth()
async deleteCircle(@Param('id', ParseIntPipe) id: number) {
await this.prisma.circle.delete({ where: { id } });
return { ok: true };
}
// --- Community Posts Admin ---
@Get('posts')
@UseGuards(AuthGuard('jwt'), AdminGuard)
@ApiBearerAuth()
async adminPosts(@Query('page') page?: string, @Query('pageSize') pageSize?: string, @Query('status') status?: string) {
const p = Math.max(1, Number(page) || 1);
const ps = Math.min(100, Math.max(1, Number(pageSize) || 20));
const where: any = {};
if (status) where.status = status;
const [items, total] = await Promise.all([
this.prisma.post.findMany({
where, orderBy: { createdAt: 'desc' }, skip: (p - 1) * ps, take: ps,
include: { user: { select: { id: true, nickname: true } }, _count: { select: { comments: true, likes: true } } },
}),
this.prisma.post.count({ where }),
]);
return { items, total, page: p, pageSize: ps };
}
@Get('posts/:id')
@UseGuards(AuthGuard('jwt'), AdminGuard)
@ApiBearerAuth()
async adminPost(@Param('id', ParseIntPipe) id: number) {
const item = await this.prisma.post.findUnique({
where: { id },
include: { user: { select: { id: true, nickname: true } }, _count: { select: { comments: true, likes: true } } },
});
if (!item) throw new HttpException('帖子不存在', HttpStatus.NOT_FOUND);
return item;
}
@Put('posts/:id/status')
@UseGuards(AuthGuard('jwt'), AdminGuard)
@ApiBearerAuth()
async updatePostStatus(@Param('id', ParseIntPipe) id: number, @Body() body: { status: string }) {
const item = await this.prisma.post.update({ where: { id }, data: { status: body.status } });
return item;
}
@Delete('posts/:id')
@UseGuards(AuthGuard('jwt'), AdminGuard)
@ApiBearerAuth()
async deletePost(@Param('id', ParseIntPipe) id: number) {
await this.prisma.post.delete({ where: { id } });
return { ok: true };
}
// --- AI Tools Admin ---
@Get('tools')
@UseGuards(AuthGuard('jwt'), AdminGuard)
@ApiBearerAuth()
async adminTools(@Query('page') page?: string, @Query('pageSize') pageSize?: string, @Query('search') search?: string) {
const p = Math.max(1, Number(page) || 1);
const ps = Math.min(100, Math.max(1, Number(pageSize) || 20));
const where: any = {};
if (search) where.name = { contains: search };
const [items, total] = await Promise.all([
this.prisma.tool.findMany({ where, orderBy: { createdAt: 'desc' }, skip: (p - 1) * ps, take: ps }),
this.prisma.tool.count({ where }),
]);
return { items, total, page: p, pageSize: ps };
}
@Get('tools/:id')
@UseGuards(AuthGuard('jwt'), AdminGuard)
@ApiBearerAuth()
async adminTool(@Param('id', ParseIntPipe) id: number) {
const item = await this.prisma.tool.findUnique({ where: { id } });
if (!item) throw new HttpException('工具不存在', HttpStatus.NOT_FOUND);
return item;
}
@Post('tools')
@UseGuards(AuthGuard('jwt'), AdminGuard)
@ApiBearerAuth()
async createTool(@Body() body: any) {
const item = await this.prisma.tool.create({ data: body });
return item;
}
@Put('tools/:id')
@UseGuards(AuthGuard('jwt'), AdminGuard)
@ApiBearerAuth()
async updateTool(@Param('id', ParseIntPipe) id: number, @Body() body: any) {
const { id: _id, ...data } = body;
const item = await this.prisma.tool.update({ where: { id }, data });
return item;
}
@Delete('tools/:id')
@UseGuards(AuthGuard('jwt'), AdminGuard)
@ApiBearerAuth()
async deleteTool(@Param('id', ParseIntPipe) id: number) {
await this.prisma.tool.delete({ where: { id } });
return { ok: true };
}
// --- AI Models Admin ---
@Get('models')
@UseGuards(AuthGuard('jwt'), AdminGuard)
@ApiBearerAuth()
async adminModels(@Query('page') page?: string, @Query('pageSize') pageSize?: string) {
const p = Math.max(1, Number(page) || 1);
const ps = Math.min(100, Math.max(1, Number(pageSize) || 20));
const [items, total] = await Promise.all([
this.prisma.aiModel.findMany({ orderBy: { sortOrder: 'asc' }, skip: (p - 1) * ps, take: ps }),
this.prisma.aiModel.count(),
]);
return { items, total, page: p, pageSize: ps };
}
@Get('models/:id')
@UseGuards(AuthGuard('jwt'), AdminGuard)
@ApiBearerAuth()
async adminModel(@Param('id', ParseIntPipe) id: number) {
const item = await this.prisma.aiModel.findUnique({ where: { id } });
if (!item) throw new HttpException('模型不存在', HttpStatus.NOT_FOUND);
return item;
}
@Post('models')
@UseGuards(AuthGuard('jwt'), AdminGuard)
@ApiBearerAuth()
async createModel(@Body() body: any) {
const item = await this.prisma.aiModel.create({ data: body });
return item;
}
@Put('models/:id')
@UseGuards(AuthGuard('jwt'), AdminGuard)
@ApiBearerAuth()
async updateModel(@Param('id', ParseIntPipe) id: number, @Body() body: any) {
const { id: _id, ...data } = body;
const item = await this.prisma.aiModel.update({ where: { id }, data });
return item;
}
@Delete('models/:id')
@UseGuards(AuthGuard('jwt'), AdminGuard)
@ApiBearerAuth()
async deleteModel(@Param('id', ParseIntPipe) id: number) {
await this.prisma.aiModel.delete({ where: { id } });
return { ok: true };
}
}
View File
+2 -1
View File
@@ -12,9 +12,10 @@ import { PublicController } from './public.controller';
import { CoursesModule } from '../courses/courses.module';
import { AuthModule } from '../auth/auth.module';
import { AIModule } from '../ai/ai.module';
import { PaymentModule } from '../payment/payment.module';
@Module({
imports: [CoursesModule, AuthModule, AIModule],
imports: [CoursesModule, AuthModule, AIModule, PaymentModule],
controllers: [AdminController, AnalyticsController, SettingsController, OperationsController, UsersController, PublicController],
providers: [AdminService, AdminGuard, AdminAiAssistantService],
exports: [AdminService],
View File
+35
View File
@@ -208,6 +208,15 @@ export class AdminAiAssistantService {
{
name: 'delete-admin', description: '禁用一个管理员账号', parameters: { id: { type: 'number', description: '管理员ID' } },
},
// ---- 全局搜索 ----
{
name: 'search', description: '全局搜索用户、订单、课程、内容等',
parameters: { q: { type: 'string', description: '搜索关键词(必填)' }, type: { type: 'string', enum: ['all', 'users', 'orders', 'courses', 'contents'], description: '搜索范围(可选,默认全部)' } },
},
// ---- 通知批量操作 ----
{
name: 'mark-all-notifications-read', description: '将所有系统通知标记为已读', parameters: {},
},
// ---- 企业版 ----
{
name: 'get-enterprise-orgs', description: '查看所有企业组织', parameters: {},
@@ -383,6 +392,32 @@ export class AdminAiAssistantService {
return { success: true, data: { id: call.params.id, status: newPromptStatus }, summary: `提示词 ${call.params.id} 状态已切换为 ${newPromptStatus}` };
}
case 'search': {
const q = call.params.q;
if (!q) throw new Error('请输入搜索关键词');
const results: any[] = [];
if (call.params.type === 'all' || call.params.type === 'users') {
const users = await this.prisma.user.findMany({ where: { deletedAt: null, OR: [{ nickname: { contains: q } }, { phone: { contains: q } }, { email: { contains: q } }] }, select: { id: true, nickname: true, phone: true, email: true, status: true }, take: 20 });
results.push(...users.map(u => ({ ...u, _type: 'user' })));
}
if (call.params.type === 'all' || call.params.type === 'orders') {
const orders = await this.prisma.order.findMany({ where: { OR: [{ orderNo: { contains: q } }] }, take: 20, orderBy: { createdAt: 'desc' } });
results.push(...orders.map(o => ({ ...o, _type: 'order' })));
}
if (call.params.type === 'all' || call.params.type === 'courses') {
const courses = await this.prisma.course.findMany({ where: { deletedAt: null, OR: [{ title: { contains: q } }, { description: { contains: q } }] }, select: { id: true, title: true, status: true }, take: 20 });
results.push(...courses.map(c => ({ ...c, _type: 'course' })));
}
if (call.params.type === 'all' || call.params.type === 'contents') {
const contents = await this.prisma.content.findMany({ where: { deletedAt: null, OR: [{ title: { contains: q } }, { summary: { contains: q } }] }, select: { id: true, title: true, status: true }, take: 20 });
results.push(...contents.map(c => ({ ...c, _type: 'content' })));
}
return { success: true, data: { results, total: results.length }, summary: `找到 ${results.length} 条结果,共 ${results.filter(r => r._type === 'user').length} 个用户、${results.filter(r => r._type === 'order').length} 个订单、${results.filter(r => r._type === 'course').length} 个课程、${results.filter(r => r._type === 'content').length} 个内容` };
}
case 'mark-all-notifications-read': {
await this.prisma.notification.updateMany({ where: { isRead: false }, data: { isRead: true } });
return { success: true, data: {}, summary: '所有通知已标记为已读' };
}
case 'get-enterprise-orgs': {
const items = await this.prisma.organization.findMany({ take: 50, orderBy: { createdAt: 'desc' } });
return { success: true, data: { items, total: items.length }, summary: `${items.length} 个企业组织` };
+3 -3
View File
@@ -138,7 +138,7 @@ export class AnalyticsController {
if (type === 'users') {
const data = await this.prisma.$queryRaw<{ date: string; count: bigint }[]>`
SELECT DATE(created_at) as date, COUNT(*) as count
FROM User
FROM users
WHERE deleted_at IS NULL AND created_at >= ${startDate}
GROUP BY DATE(created_at)
ORDER BY date
@@ -149,7 +149,7 @@ export class AnalyticsController {
if (type === 'orders') {
const data = await this.prisma.$queryRaw<{ date: string; count: bigint; revenue: bigint }[]>`
SELECT DATE(created_at) as date, COUNT(*) as count, COALESCE(SUM(amount), 0) as revenue
FROM \`Order\`
FROM orders
WHERE status = 'PAID' AND created_at >= ${startDate}
GROUP BY DATE(created_at)
ORDER BY date
@@ -160,7 +160,7 @@ export class AnalyticsController {
if (type === 'sessions') {
const data = await this.prisma.$queryRaw<{ date: string; count: bigint }[]>`
SELECT DATE(created_at) as date, COUNT(*) as count
FROM sandbox_session
FROM sandbox_sessions
WHERE created_at >= ${startDate}
GROUP BY DATE(created_at)
ORDER BY date
+8 -3
View File
@@ -107,13 +107,18 @@ export class OperationsController {
}
@Put('config/:key')
async updateConfig(@Param('key') key: string, @Body() body: { value: string }) {
async updateConfig(@Param('key') key: string, @Body() body: { value: string; description?: string; category?: string }) {
const existing = await this.prisma.systemConfig.findUnique({ where: { key } });
if (existing) {
return this.prisma.systemConfig.update({ where: { key }, data: { value: body.value } });
return this.prisma.systemConfig.update({ where: { key }, data: { value: body.value, ...(body.description !== undefined ? { description: body.description } : {}) } });
}
return this.prisma.systemConfig.create({
data: { key, value: body.value, category: 'other' },
data: { key, value: body.value, category: body.category || 'other', description: body.description || '' },
});
}
@Delete('config/:key')
async deleteConfig(@Param('key') key: string) {
return this.prisma.systemConfig.delete({ where: { key } });
}
}
+11
View File
@@ -7,6 +7,17 @@ import { PrismaService } from '../../prisma/prisma.service';
export class PublicController {
constructor(private prisma: PrismaService) {}
@Get('stats')
async getStats() {
const [courses, prompts, tools, users] = await Promise.all([
this.prisma.course.count({ where: { status: 'PUBLISHED', deletedAt: null } }),
this.prisma.prompt.count({ where: { status: 'PUBLISHED' } }),
this.prisma.tool.count({ where: { status: 'PUBLISHED' } }),
this.prisma.user.count({ where: { status: 'ACTIVE' } }),
]);
return { courses, prompts, tools, users };
}
@Get('banners')
async banners(@Query('position') position?: string) {
const now = new Date();
+11 -42
View File
@@ -13,23 +13,12 @@ export class SettingsController {
@Get('roles')
async roles() {
return {
items: await this.prisma.adminRole.findMany({
where: { status: 'ACTIVE' },
orderBy: { createdAt: 'desc' },
}),
};
return { items: await this.prisma.adminRole.findMany({ where: { status: 'ACTIVE' }, orderBy: { createdAt: 'desc' } }) };
}
@Post('roles')
async createRole(@Body() body: { name: string; description?: string; permissions?: string[] }) {
return this.prisma.adminRole.create({
data: {
name: body.name,
description: body.description || '',
permissions: body.permissions || [],
},
});
return this.prisma.adminRole.create({ data: { name: body.name, description: body.description || '', permissions: body.permissions || [] } });
}
@Put('roles/:id')
@@ -48,27 +37,14 @@ export class SettingsController {
@Get('admins')
async admins() {
return {
items: await this.prisma.adminUser.findMany({
where: { status: 'ACTIVE' },
include: { role: true },
orderBy: { createdAt: 'desc' },
}),
};
return { items: await this.prisma.adminUser.findMany({ where: { status: 'ACTIVE' }, include: { role: true }, orderBy: { createdAt: 'desc' } }) };
}
@Post('admins')
async createAdmin(@Body() body: { username: string; password: string; nickname?: string; roleId?: string }) {
const bcrypt = require('bcryptjs');
const passwordHash = await bcrypt.hash(body.password, 10);
return this.prisma.adminUser.create({
data: {
username: body.username,
passwordHash,
nickname: body.nickname || '',
roleId: body.roleId || null,
},
});
return this.prisma.adminUser.create({ data: { username: body.username, passwordHash, nickname: body.nickname || '', roleId: body.roleId || null } });
}
@Put('admins/:id')
@@ -89,20 +65,13 @@ export class SettingsController {
permissions() {
return {
items: [
{ key: 'dashboard', name: '仪表盘', category: '首页' },
{ key: 'users', name: '用户管理', category: '用户' },
{ key: 'courses', name: '课程管理', category: '内容' },
{ key: 'prompts', name: '提示词管理', category: '内容' },
{ key: 'contents', name: '内容管理', category: '内容' },
{ key: 'tools', name: '工具管理', category: '内容' },
{ key: 'orders', name: '订单管理', category: '交易' },
{ key: 'enterprise', name: '企业版管理', category: '交易' },
{ key: 'comments', name: '评论审核', category: '社区' },
{ key: 'analytics', name: '数据分析', category: '统计' },
{ key: 'roles', name: '角色权限', category: '设置' },
{ key: 'admins', name: '管理员', category: '设置' },
{ key: 'config', name: '系统配置', category: '设置' },
{ key: 'banners', name: 'Banner管理', category: '运营' },
{ key: 'dashboard', name: '仪表盘', category: '首页' }, { key: 'users', name: '用户管理', category: '用户' },
{ key: 'courses', name: '课程管理', category: '内容' }, { key: 'prompts', name: '提示词管理', category: '内容' },
{ key: 'contents', name: '内容管理', category: '内容' }, { key: 'tools', name: '工具管理', category: '内容' },
{ key: 'orders', name: '订单管理', category: '交易' }, { key: 'enterprise', name: '企业版管理', category: '交易' },
{ key: 'comments', name: '评论审核', category: '社区' }, { key: 'analytics', name: '数据分析', category: '统计' },
{ key: 'roles', name: '角色权限', category: '设置' }, { key: 'admins', name: '管理', category: '设置' },
{ key: 'config', name: '系统配置', category: '设置' }, { key: 'banners', name: 'Banner管理', category: '运营' },
{ key: 'notifications', name: '推送管理', category: '运营' },
],
};
+1 -1
View File
@@ -51,7 +51,7 @@ describe('AdminService', () => {
id: 1,
username: 'admin',
passwordHash: '$2a$10$hashed',
role: 'superadmin',
role: { name: 'superadmin' },
status: 'ACTIVE',
};
View File
@@ -0,0 +1,73 @@
import { Controller, Post, Body, Req, UseGuards, HttpException, HttpStatus } from '@nestjs/common';
import { ApiTags, ApiBearerAuth } from '@nestjs/swagger';
import { AuthGuard } from '@nestjs/passport';
import { AIGatewayService } from '../ai/ai-gateway.service';
import { UserAiAssistantService } from './ai-assistant.service';
@ApiTags('AI 助手')
@Controller('ai-assistant')
export class AiAssistantController {
constructor(
private aiGateway: AIGatewayService,
private aiAssistant: UserAiAssistantService,
) {}
@Post('chat')
@UseGuards(AuthGuard('jwt'))
@ApiBearerAuth()
async chat(
@Req() req: any,
@Body() body: { messages: { role: string; content: string }[]; pageContext?: string },
) {
try {
const { messages, pageContext } = body;
const toolsDesc = this.aiAssistant.getToolsDescription();
const systemContent = `你是宇之然 AI 学习与实践平台的智能助手,帮助用户了解和使用平台的所有功能。
你可以回答用户的问题,也可以调用工具来执行操作(如搜索内容、查看学习分析、管理通知等)。
${pageContext ? `当前页面:${pageContext}\n\n` : ''}可用工具列表(需要执行操作时,返回 JSON{"tool":"工具名","params":{...},"description":"简述"}):
${toolsDesc}
注意:
1. 当用户请求执行具体操作(如搜索、查看数据、管理通知)时,返回一个 JSON 工具调用。
2. 普通对话问题直接文字回答,不需要返回 JSON。
3. 每次只需要返回一个 JSON 工具调用,不要包含多余文字。
4. 对于 navigate 工具,前端会自动跳转,你只需要返回 JSON 即可。`;
const apiMessages = [
{ role: 'system' as const, content: systemContent },
...messages.map(m => ({ role: m.role as 'user' | 'assistant', content: m.content })),
];
const reply = await this.aiGateway.chat('general', apiMessages, { temperature: 0.7, max_tokens: 2000 });
return { reply };
} catch (err: any) {
throw new HttpException(err.message || 'AI 助手请求失败', HttpStatus.INTERNAL_SERVER_ERROR);
}
}
@Post('action')
@UseGuards(AuthGuard('jwt'))
@ApiBearerAuth()
async action(
@Req() req: any,
@Body() body: { tool: string; params: Record<string, any>; messages: { role: string; content: string }[] },
) {
const userId = req.user.userId;
const result = await this.aiAssistant.execute({ tool: body.tool, params: body.params }, userId);
try {
const apiMessages = [
{ role: 'system' as const, content: '你是一个AI助手,下面是用户请求的执行结果,请用自然语言简洁总结给用户。' },
...body.messages.map(m => ({ role: m.role as 'user' | 'assistant', content: m.content })),
{ role: 'assistant' as const, content: `【工具执行结果】\n工具: ${body.tool}\n参数: ${JSON.stringify(body.params)}\n结果: ${result.summary}\n数据: ${JSON.stringify(result.data)}` },
];
const reply = await this.aiGateway.chat('general', apiMessages, { temperature: 0.3, max_tokens: 1000 });
return { reply, result };
} catch {
return { reply: result.summary, result };
}
}
}
@@ -0,0 +1,13 @@
import { Module } from '@nestjs/common';
import { AiAssistantController } from './ai-assistant.controller';
import { UserAiAssistantService } from './ai-assistant.service';
import { SkillsModule } from '../skills/skills.module';
import { AIModule } from '../ai/ai.module';
@Module({
imports: [SkillsModule, AIModule],
controllers: [AiAssistantController],
providers: [UserAiAssistantService],
exports: [UserAiAssistantService],
})
export class AiAssistantModule {}
@@ -0,0 +1,431 @@
import { Injectable } from '@nestjs/common';
import { PrismaService } from '../../prisma/prisma.service';
import { SkillsService } from '../skills/skills.service';
export interface ToolDefinition {
name: string;
description: string;
parameters: Record<string, any>;
}
export interface ToolCall {
tool: string;
params: Record<string, any>;
description?: string;
}
export interface ToolResult {
success: boolean;
data: any;
summary: string;
}
const KNOWLEDGE_DOMAINS = [
{ id: 'ai-basics', name: 'AI 基础知识', keywords: ['AI', '人工智能', '大模型', 'chatgpt', 'gpt', '大语言模型', 'llm', '深度学习', '神经网络', 'machine learning', '机器学习'] },
{ id: 'prompt-engineering', name: '提示词工程', keywords: ['提示词', 'prompt', 'system prompt', 'role', 'few-shot', 'chain-of-thought', 'cot'] },
{ id: 'programming', name: '编程开发', keywords: ['python', 'javascript', 'typescript', 'java', '代码', '函数', '算法', 'debug', 'bug', '编程', '开发', 'react', 'vue', 'node'] },
{ id: 'writing', name: '写作创作', keywords: ['写作', '文章', '文案', '润色', '作文', '创作', '故事', '小说', '博客'] },
{ id: 'english', name: '英语学习', keywords: ['英语', 'english', '翻译', '语法', 'grammar', 'vocabulary', '口语', '写作', '阅读'] },
{ id: 'data-science', name: '数据分析', keywords: ['数据', '分析', '统计', '图表', '可视化', 'sql', 'excel', 'pandas', 'numpy', '数据分析'] },
{ id: 'office', name: '办公效率', keywords: ['ppt', 'excel', 'word', '办公', '邮件', '报告', '文档', '会议', '总结'] },
{ id: 'career', name: '职业发展', keywords: ['简历', '面试', '求职', '职业', '工作', '升职', '薪资'] },
];
const LEARNING_PATH_STAGES = [
{
id: 'basics', title: '认识大模型',
tasks: [
{ label: '了解 AI 基本概念', keyword: '人工智能' },
{ label: '认识大语言模型', keyword: '大模型' },
{ label: '体验 AI 对话', keyword: '' },
],
},
{
id: 'prompt', title: '提示词工程',
tasks: [
{ label: '了解提示词基础', keyword: '提示词' },
{ label: '练习提示词编写', keyword: '' },
{ label: '保存优质提示词', keyword: '' },
],
},
{
id: 'advanced', title: '模型微调与高级应用',
tasks: [
{ label: '了解模型微调', keyword: '微调' },
{ label: '了解 RAG', keyword: 'rag' },
{ label: '了解 Function Calling', keyword: 'function calling' },
],
},
{
id: 'agent', title: 'Agent 开发',
tasks: [
{ label: '了解 AI Agent', keyword: 'agent' },
{ label: '学习工具调用', keyword: 'tool' },
{ label: '实践项目', keyword: '' },
],
},
];
@Injectable()
export class UserAiAssistantService {
constructor(
private prisma: PrismaService,
private skillsService: SkillsService,
) {}
private userTools(): ToolDefinition[] {
return [
{
name: 'navigate',
description: '跳转到网站的某个页面',
parameters: { path: { type: 'string', description: '页面路径,如 /sandbox、/skills、/my/member、/learning、/models、/prompts、/compare、/community、/courses、/my、/about' } },
},
{
name: 'search',
description: '搜索平台上的课程、提示词、工具、文章等内容',
parameters: { q: { type: 'string', description: '搜索关键词(必填)' }, type: { type: 'string', enum: ['all', 'courses', 'prompts', 'tools', 'contents'], description: '搜索类型(可选,默认全部)' } },
},
{
name: 'list-skills',
description: '浏览技能列表,可按分类、难度、关键词过滤',
parameters: { category: { type: 'string', description: '分类(可选,如 basic/technical/creative' }, difficulty: { type: 'string', description: '难度(可选,如 beginner/intermediate/advanced' }, search: { type: 'string', description: '搜索关键词(可选)' } },
},
{
name: 'get-skill',
description: '查看某个技能的详细信息',
parameters: { id: { type: 'string', description: '技能ID(必填),如 coding、writing、english、data-analysis' } },
},
{
name: 'get-skill-categories',
description: '查看所有技能分类',
parameters: {},
},
{
name: 'get-learning-analytics',
description: '查看学情分析(知识领域掌握度),需要登录',
parameters: {},
},
{
name: 'get-learning-path',
description: '查看学习路径进度,需要登录',
parameters: {},
},
{
name: 'list-notifications',
description: '查看我的通知列表,需要登录',
parameters: { page: { type: 'number', description: '页码(可选)' }, pageSize: { type: 'number', description: '每页数量(可选)' } },
},
{
name: 'get-unread-count',
description: '查看未读通知数量,需要登录',
parameters: {},
},
{
name: 'mark-notification-read',
description: '标记某条通知为已读,需要登录',
parameters: { id: { type: 'number', description: '通知ID(必填)' } },
},
{
name: 'mark-all-read',
description: '将所有通知标记为已读,需要登录',
parameters: {},
},
{
name: 'get-profile',
description: '查看我的个人信息(昵称、手机号、会员状态等),需要登录',
parameters: {},
},
{
name: 'list-models',
description: '查看平台支持的AI模型列表',
parameters: {},
},
{
name: 'list-prompts',
description: '浏览提示词列表',
parameters: {},
},
{
name: 'list-courses',
description: '浏览课程列表',
parameters: {},
},
{
name: 'list-user-orders',
description: '查看我的订单记录,需要登录',
parameters: {},
},
{
name: 'get-current-subscription',
description: '查看当前会员订阅信息,需要登录',
parameters: {},
},
];
}
getAvailableTools(): ToolDefinition[] {
return this.userTools();
}
getToolsDescription(): string {
return this.getAvailableTools().map(t => {
const params = Object.entries(t.parameters)
.map(([k, v]: any) => ` - ${k}: ${v.type}${v.description ? ' (' + v.description + ')' : ''}${v.enum ? ' [' + v.enum.join('|') + ']' : ''}`)
.join('\n');
return `- **${t.name}**: ${t.description}\n${params}`;
}).join('\n\n');
}
async execute(call: ToolCall, userId?: number): Promise<ToolResult> {
try {
switch (call.tool) {
case 'navigate':
return { success: true, data: { path: call.params.path }, summary: `跳转到 ${call.params.path}` };
case 'search': {
const { q, type } = call.params;
if (!q) throw new Error('请输入搜索关键词');
const results = await this.search(q, type || 'all');
return { success: true, data: results, summary: `找到 ${results.total} 条结果:${results.results.slice(0, 5).map(r => r.title).join('、')}` };
}
case 'list-skills': {
const skills = await this.skillsService.findAll(call.params);
return { success: true, data: skills, summary: `${skills.total} 个技能:${skills.items.map(s => s.name).join('、')}` };
}
case 'get-skill': {
const skill = await this.skillsService.findById(call.params.id);
if (!skill) throw new Error(`技能 ${call.params.id} 不存在`);
return { success: true, data: skill, summary: `技能「${skill.name}」(${skill.category} - ${skill.difficulty})${skill.description}` };
}
case 'get-skill-categories': {
const cats = await this.skillsService.getCategories();
return { success: true, data: { categories: cats }, summary: `${cats.length} 个分类:${cats.map(c => c.name).join('、')}` };
}
case 'get-learning-analytics': {
if (!userId) throw new Error('需要登录');
const analytics = await this.getLearningAnalytics(userId);
return { success: true, data: analytics, summary: `总对话 ${analytics.totalSessions} 次,${analytics.domains.filter(d => d.weak).length} 个薄弱领域,建议从 ${analytics.recommendations[0]?.title || '探索更多'} 开始` };
}
case 'get-learning-path': {
if (!userId) throw new Error('需要登录');
const path = await this.getLearningPath(userId);
return { success: true, data: path, summary: `学习路径共 ${path.length} 个阶段:${path.map(s => `${s.title}(${s.progress}%)`).join('、')}` };
}
case 'list-notifications': {
if (!userId) throw new Error('需要登录');
const page = call.params.page || 1;
const pageSize = call.params.pageSize || 20;
const [items, total] = await Promise.all([
this.prisma.notification.findMany({
where: { userId }, orderBy: { createdAt: 'desc' }, skip: (page - 1) * pageSize, take: pageSize,
}),
this.prisma.notification.count({ where: { userId } }),
]);
return { success: true, data: { items, total, page, pageSize }, summary: `${total} 条通知` };
}
case 'get-unread-count': {
if (!userId) throw new Error('需要登录');
const count = await this.prisma.notification.count({ where: { userId, isRead: false } });
return { success: true, data: { count }, summary: `${count} 条未读通知` };
}
case 'mark-notification-read': {
if (!userId) throw new Error('需要登录');
await this.prisma.notification.updateMany({ where: { id: call.params.id, userId }, data: { isRead: true } });
return { success: true, data: {}, summary: `通知 ${call.params.id} 已标记为已读` };
}
case 'mark-all-read': {
if (!userId) throw new Error('需要登录');
await this.prisma.notification.updateMany({ where: { userId, isRead: false }, data: { isRead: true } });
return { success: true, data: {}, summary: '所有通知已标记为已读' };
}
case 'get-profile': {
if (!userId) throw new Error('需要登录');
const profile = await this.prisma.user.findUnique({
where: { id: userId },
select: { id: true, phone: true, email: true, nickname: true, avatar: true, status: true, memberPlan: true, memberExpire: true, sandboxDaily: true, createdAt: true },
});
if (!profile) throw new Error('用户不存在');
return { success: true, data: profile, summary: `用户 ${profile.nickname || profile.phone || profile.email}${profile.memberPlan !== 'FREE' ? ',会员: ' + profile.memberPlan : ',当前为免费用户'}${profile.memberExpire ? ',到期: ' + profile.memberExpire.toISOString().slice(0, 10) : ''}` };
}
case 'list-models': {
const models = await this.prisma.aiModel.findMany({ where: { status: 'ACTIVE' }, orderBy: { sortOrder: 'asc' } });
if (models.length === 0) {
return { success: true, data: { models: [] }, summary: '目前没有可用的AI模型,请稍后再试' };
}
return { success: true, data: { models }, summary: `${models.length} 个模型:${models.map(m => m.name).join('、')}` };
}
case 'list-prompts': {
const items = await this.prisma.prompt.findMany({
where: { deletedAt: null, status: 'PUBLISHED' },
take: 50, orderBy: { createdAt: 'desc' },
select: { id: true, title: true, description: true, tags: true },
});
return { success: true, data: { items, total: items.length }, summary: `${items.length} 个提示词:${items.map(p => p.title).join('、')}` };
}
case 'list-courses': {
const items = await this.prisma.course.findMany({
where: { deletedAt: null, status: 'PUBLISHED' },
take: 50, orderBy: { createdAt: 'desc' },
select: { id: true, title: true, description: true, price: true, isFree: true },
});
const freeCount = items.filter(c => c.isFree).length;
return { success: true, data: { items, total: items.length }, summary: `${items.length} 个课程(${freeCount} 个免费):${items.map(c => c.title).join('、')}` };
}
case 'list-user-orders': {
if (!userId) throw new Error('需要登录');
const items = await this.prisma.order.findMany({
where: { userId },
take: 50, orderBy: { createdAt: 'desc' },
});
const paidAmount = items.filter(o => o.status === 'PAID').reduce((s, o) => s + o.amount, 0);
return { success: true, data: { items, total: items.length, paidAmount }, summary: `${items.length} 笔订单,已支付总额 ¥${paidAmount}` };
}
case 'get-current-subscription': {
if (!userId) throw new Error('需要登录');
const now = new Date();
const sub = await this.prisma.subscription.findFirst({
where: { userId, status: 'ACTIVE', endDate: { gt: now } },
orderBy: { endDate: 'desc' },
});
if (!sub) {
const user = await this.prisma.user.findUnique({ where: { id: userId }, select: { memberPlan: true, memberExpire: true } });
if (!user) throw new Error('用户不存在');
return { success: true, data: null, summary: user.memberPlan !== 'FREE' ? `会员状态: ${user.memberPlan},到期 ${user.memberExpire?.toISOString().slice(0, 10) || '未知'}` : '当前为免费用户,暂无有效订阅' };
}
return { success: true, data: sub, summary: `当前订阅: ${sub.plan},到期 ${sub.endDate.toISOString().slice(0, 10)},剩余 ${Math.ceil((sub.endDate.getTime() - Date.now()) / 86400000)}` };
}
default:
throw new Error(`未知工具: ${call.tool}`);
}
} catch (err: any) {
return { success: false, data: null, summary: `执行失败: ${err.message}` };
}
}
private async getLearningAnalytics(userId: number) {
const sessions = await this.prisma.sandboxSession.findMany({
where: { userId }, orderBy: { createdAt: 'desc' }, take: 100,
});
const domainCounts: Record<string, number> = {};
const domainDates: Record<string, string> = {};
const totalSessions = sessions.length;
for (const d of KNOWLEDGE_DOMAINS) domainCounts[d.id] = 0;
for (const session of sessions) {
const searchText = `${session.title} ${session.messages || ''}`.toLowerCase();
for (const domain of KNOWLEDGE_DOMAINS) {
if (domain.keywords.some(kw => searchText.includes(kw))) {
domainCounts[domain.id] = (domainCounts[domain.id] || 0) + 1;
if (!domainDates[domain.id] || session.createdAt.toISOString() > domainDates[domain.id]) {
domainDates[domain.id] = session.createdAt.toISOString();
}
}
}
}
const domains = KNOWLEDGE_DOMAINS.map(d => {
const count = domainCounts[d.id] || 0;
const mastery = Math.min(Math.round((count / Math.max(totalSessions * 0.3, 1)) * 100), 100);
return { id: d.id, name: d.name, sessionCount: count, mastery, lastActive: domainDates[d.id] || null, weak: mastery < 30 };
});
const weakDomains = domains.filter(d => d.weak);
const recommendations = weakDomains.length > 0
? weakDomains.slice(0, 3).flatMap(d => [
{ title: `了解 ${d.name}`, url: '/sandbox' },
])
: [{ title: '探索更多知识领域', url: '/sandbox' }];
return { domains, totalSessions, weakDomains: weakDomains.map(d => d.name), recommendations };
}
private async getLearningPath(userId: number) {
const sessions = await this.prisma.sandboxSession.findMany({
where: { userId }, select: { title: true, messages: true },
});
const allText = sessions.map(s => `${s.title} ${s.messages || ''}`.toLowerCase()).join(' ');
return LEARNING_PATH_STAGES.map(stage => {
const completedCount = stage.tasks.filter(t => !t.keyword || allText.includes(t.keyword)).length;
const progress = stage.tasks.length > 0 ? Math.round((completedCount / stage.tasks.length) * 100) : 0;
return { ...stage, completedCount, totalTasks: stage.tasks.length, progress, unlocked: true };
});
}
private async search(q: string, type: string) {
const results: any[] = [];
let total = 0;
if (type === 'all' || type === 'courses') {
const [items, count] = await Promise.all([
this.prisma.course.findMany({
where: { deletedAt: null, status: 'PUBLISHED', OR: [{ title: { contains: q } }, { description: { contains: q } }] },
select: { id: true, title: true, description: true, isFree: true },
take: type === 'courses' ? 20 : 5,
}),
this.prisma.course.count({ where: { deletedAt: null, status: 'PUBLISHED', OR: [{ title: { contains: q } }, { description: { contains: q } }] } }),
]);
results.push(...items.map(i => ({ ...i, _type: 'course' })));
total += count;
}
if (type === 'all' || type === 'prompts') {
const [items, count] = await Promise.all([
this.prisma.prompt.findMany({
where: { deletedAt: null, status: 'PUBLISHED', OR: [{ title: { contains: q } }, { description: { contains: q } }, { content: { contains: q } }] },
select: { id: true, title: true, description: true },
take: type === 'prompts' ? 20 : 5,
}),
this.prisma.prompt.count({ where: { deletedAt: null, status: 'PUBLISHED', OR: [{ title: { contains: q } }, { description: { contains: q } }, { content: { contains: q } }] } }),
]);
results.push(...items.map(i => ({ ...i, _type: 'prompt' })));
total += count;
}
if (type === 'all' || type === 'tools') {
const [items, count] = await Promise.all([
this.prisma.tool.findMany({
where: { deletedAt: null, OR: [{ name: { contains: q } }, { description: { contains: q } }] },
select: { id: true, name: true, description: true },
take: type === 'tools' ? 20 : 5,
}),
this.prisma.tool.count({ where: { deletedAt: null, OR: [{ name: { contains: q } }, { description: { contains: q } }] } }),
]);
results.push(...items.map(i => ({ ...i, _type: 'tool' })));
total += count;
}
if (type === 'all' || type === 'contents') {
const [items, count] = await Promise.all([
this.prisma.content.findMany({
where: { deletedAt: null, status: 'PUBLISHED', OR: [{ title: { contains: q } }, { summary: { contains: q } }] },
select: { id: true, title: true, summary: true },
take: type === 'contents' ? 20 : 5,
}),
this.prisma.content.count({ where: { deletedAt: null, status: 'PUBLISHED', OR: [{ title: { contains: q } }, { summary: { contains: q } }] } }),
]);
results.push(...items.map(i => ({ ...i, _type: 'content' })));
total += count;
}
return { results, total };
}
}
+102 -4
View File
@@ -21,6 +21,7 @@ export interface ModelInfo {
interface AIProvider {
name: string;
chat(messages: ChatMessage[], options?: ChatOptions): Promise<string>;
chatStream?(messages: ChatMessage[], options?: ChatOptions): AsyncIterable<string>;
}
const MODEL_CATALOG: Record<string, ModelInfo> = {
@@ -30,7 +31,7 @@ const MODEL_CATALOG: Record<string, ModelInfo> = {
'gpt-4': { id: 'gpt-4', provider: 'OpenAI', capabilities: ['chat', 'code', 'vision'], contextWindow: 32768 },
'deepseek-v4-flash': { id: 'deepseek-v4-flash', provider: '商汤科技', capabilities: ['chat', 'code'], contextWindow: 32768 },
'sensenova-6.7-flash-lite': { id: 'sensenova-6.7-flash-lite', provider: '商汤科技', capabilities: ['chat', 'code'], contextWindow: 32768 },
'sensenova-u1-fast': { id: 'sensenova-u1-fast', provider: '商汤科技', capabilities: ['chat', 'code', 'vision'], contextWindow: 65536 },
}
@Injectable()
@@ -64,7 +65,7 @@ export class AIGatewayService {
if (!apiUrl.endsWith('/chat/completions')) {
apiUrl = apiUrl.replace(/\/+$/, '') + '/chat/completions';
}
const models = ['deepseek-v4-flash', 'sensenova-6.7-flash-lite', 'sensenova-u1-fast'];
const models = ['deepseek-v4-flash', 'sensenova-6.7-flash-lite'];
for (const modelName of models) {
this.providers.set(modelName, new OpenAICompatibleProvider(
process.env.SENSENOVA_API_KEY!,
@@ -87,7 +88,7 @@ export class AIGatewayService {
'meituan/longcat-flash-lite': 'openai',
'deepseek-v4-flash': 'deepseek-v4-flash',
'sensenova-6.7-flash-lite': 'sensenova-6.7-flash-lite',
'sensenova-u1-fast': 'sensenova-u1-fast',
};
const providerKey = modelMap[model.toLowerCase()] || (model.includes('/') ? 'openai' : model);
@@ -114,6 +115,54 @@ export class AIGatewayService {
return this.fallback(messages);
}
async *chatStream(model: string, messages: ChatMessage[], options?: ChatOptions): AsyncIterable<string> {
const modelMap: Record<string, string> = {
'general': 'openai',
'openai': 'openai',
'gpt-3.5': 'openai',
'gpt-4': 'openai',
'longcat': 'openai',
'meituan/longcat-flash-lite': 'openai',
'deepseek-v4-flash': 'deepseek-v4-flash',
'sensenova-6.7-flash-lite': 'sensenova-6.7-flash-lite',
};
const providerKey = modelMap[model.toLowerCase()] || (model.includes('/') ? 'openai' : model);
const provider = this.providers.get(providerKey);
if (!this.usageStats[providerKey]) this.usageStats[providerKey] = { total: 0, success: 0, failed: 0 };
this.usageStats[providerKey].total++;
if (provider?.chatStream) {
try {
let hasContent = false;
for await (const chunk of provider.chatStream(messages, options)) {
hasContent = true;
yield chunk;
}
if (!hasContent) throw new Error('AI 返回内容为空');
this.usageStats[providerKey].success++;
} catch (err: any) {
this.logger.error(`${provider.name} 流式调用失败: ${err.message}`);
this.usageStats[providerKey].failed++;
yield this.fallback(messages);
}
} else if (provider) {
try {
const reply = await provider.chat(messages, options);
this.usageStats[providerKey].success++;
yield reply;
} catch (err: any) {
this.logger.error(`${provider.name} 调用失败: ${err.message}`);
this.usageStats[providerKey].failed++;
yield this.fallback(messages);
}
} else {
yield this.fallback(messages);
}
}
private fallback(messages: ChatMessage[]): string {
const lastMsg = typeof messages[messages.length - 1]?.content === 'string'
? messages[messages.length - 1]?.content as string : '';
@@ -188,6 +237,55 @@ class OpenAICompatibleProvider implements AIProvider {
}
const data = await res.json() as any;
return data.choices[0].message.content;
const msg = data.choices[0].message;
return msg.content || msg.reasoning_content || '';
}
async *chatStream(messages: ChatMessage[], options?: ChatOptions): AsyncIterable<string> {
const res = await fetch(this.apiUrl, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${this.apiKey}`,
},
body: JSON.stringify({
model: this.defaultModel,
messages,
temperature: options?.temperature ?? 0.7,
top_p: options?.top_p ?? 1,
max_tokens: options?.max_tokens ?? 2000,
stream: true,
}),
});
if (!res.ok) {
throw new Error(`${this.name} API error: ${res.status} ${await res.text()}`);
}
const reader = res.body!.getReader();
const decoder = new TextDecoder();
let buffer = '';
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split('\n');
buffer = lines.pop() || '';
for (const line of lines) {
const trimmed = line.trim();
if (!trimmed || !trimmed.startsWith('data:')) continue;
const data = trimmed.slice(5).trim();
if (data === '[DONE]') return;
try {
const json = JSON.parse(data);
const delta = json.choices?.[0]?.delta || {};
const content = delta.content || delta.reasoning_content || '';
if (content) yield content;
} catch {}
}
}
}
}
View File
+6 -6
View File
@@ -6,8 +6,8 @@ describe('AIGatewayService', () => {
beforeEach(async () => {
// Clear env before each test
delete process.env.DEEPSEEK_API_KEY;
delete process.env.DASHSCOPE_API_KEY;
delete process.env.OPENAI_API_KEY;
delete process.env.SENSENOVA_API_KEY;
const module: TestingModule = await Test.createTestingModule({
providers: [AIGatewayService],
@@ -70,8 +70,8 @@ describe('AIGatewayService', () => {
});
describe('model routing', () => {
it('should route deepseek models to deepseek provider (but fallback to mock)', async () => {
const result = await service.chat('deepseek-chat', [
it('should route deepseek-v4-flash to its provider (but fallback to mock)', async () => {
const result = await service.chat('deepseek-v4-flash', [
{ role: 'user', content: '你好' },
]);
@@ -79,8 +79,8 @@ describe('AIGatewayService', () => {
expect(result).toContain('宇之然 AI 助手');
});
it('should route qwen models to dashscope provider (but fallback to mock)', async () => {
const result = await service.chat('qwen-max', [
it('should route general model to openai provider (but fallback to mock)', async () => {
const result = await service.chat('general', [
{ role: 'user', content: '你好' },
]);
+42 -7
View File
@@ -1,27 +1,49 @@
import { Controller, Post, Get, Body, UseGuards, Req } from '@nestjs/common';
import { Controller, Post, Get, Body, UseGuards, Req, Res } from '@nestjs/common';
import { ApiTags, ApiBearerAuth } from '@nestjs/swagger';
import { AuthGuard } from '@nestjs/passport';
import { Throttle } from '@nestjs/throttler';
import { AuthService } from './auth.service';
import { RegisterDto } from './dto/register.dto';
import { LoginDto } from './dto/login.dto';
@ApiTags('认证')
@Controller('auth')
export class AuthController {
constructor(private authService: AuthService) {}
private setTokenCookie(res: any, accessToken: string) {
const isProd = process.env.NODE_ENV === 'production';
res.cookie('token', accessToken, {
httpOnly: true,
secure: isProd,
sameSite: 'lax',
path: '/',
maxAge: 7 * 24 * 60 * 60 * 1000,
});
}
@Post('register')
async register(@Body() body: RegisterDto) {
return this.authService.register(body);
@Throttle({ default: { limit: 3, ttl: 60000 } })
async register(@Body() body: RegisterDto, @Res({ passthrough: true }) res: any) {
const tokens = await this.authService.register(body);
this.setTokenCookie(res, tokens.accessToken);
return tokens;
}
@Post('login')
async login(@Body() body: { account: string; password: string }) {
return this.authService.login(body.account, body.password);
@Throttle({ default: { limit: 5, ttl: 60000 } })
async login(@Body() body: LoginDto, @Res({ passthrough: true }) res: any) {
const tokens = await this.authService.login(body.account, body.password);
this.setTokenCookie(res, tokens.accessToken);
return tokens;
}
@Post('refresh')
async refresh(@Body() body: { accessToken: string }) {
return this.authService.refreshAccessToken(body.accessToken);
@Throttle({ default: { limit: 10, ttl: 60000 } })
async refresh(@Body() body: { accessToken: string }, @Res({ passthrough: true }) res: any) {
const tokens = await this.authService.refreshAccessToken(body.accessToken);
this.setTokenCookie(res, tokens.accessToken);
return tokens;
}
@Get('profile')
@@ -30,4 +52,17 @@ export class AuthController {
async profile(@Req() req: any) {
return this.authService.getProfile(req.user.userId);
}
@Get('verify')
@UseGuards(AuthGuard('jwt'))
async verify(@Req() req: any) {
const profile = await this.authService.getProfile(req.user.userId);
return { ...profile, accessToken: req.headers.authorization?.replace('Bearer ', '') || '' };
}
@Post('logout')
async logout(@Res({ passthrough: true }) res: any) {
res.cookie('token', '', { httpOnly: true, maxAge: 0, path: '/' });
return { message: '已退出' };
}
}
View File
+25 -6
View File
@@ -1,4 +1,4 @@
import { Injectable, UnauthorizedException } from '@nestjs/common';
import { Injectable, UnauthorizedException, BadRequestException } from '@nestjs/common';
import { JwtService } from '@nestjs/jwt';
import * as bcrypt from 'bcryptjs';
import { PrismaService } from '../../prisma/prisma.service';
@@ -10,14 +10,32 @@ export class AuthService {
private jwtService: JwtService,
) {}
async register(data: { phone?: string; email?: string; password: string; nickname?: string }) {
async register(data: { username?: string; phone?: string; email?: string; password: string; nickname?: string }) {
if (!data.username && !data.phone && !data.email) {
throw new BadRequestException('请填写用户名、手机号或邮箱');
}
if (data.username) {
const exists = await this.prisma.user.findUnique({ where: { username: data.username } });
if (exists) throw new BadRequestException('用户名已被注册');
}
if (data.phone) {
const exists = await this.prisma.user.findUnique({ where: { phone: data.phone } });
if (exists) throw new BadRequestException('手机号已被注册');
}
if (data.email) {
const exists = await this.prisma.user.findUnique({ where: { email: data.email } });
if (exists) throw new BadRequestException('邮箱已被注册');
}
const passwordHash = await bcrypt.hash(data.password, 10);
const user = await this.prisma.user.create({
data: {
phone: data.phone,
email: data.email,
username: data.username || null,
phone: data.phone || null,
email: data.email || null,
passwordHash,
nickname: data.nickname || data.phone || data.email?.split('@')[0],
nickname: data.nickname || data.username || data.phone || data.email?.split('@')[0],
},
});
return this.generateTokens(user.id);
@@ -26,7 +44,7 @@ export class AuthService {
async login(account: string, password: string) {
const user = await this.prisma.user.findFirst({
where: {
OR: [{ phone: account }, { email: account }],
OR: [{ username: account }, { phone: account }, { email: account }],
deletedAt: null,
},
});
@@ -68,6 +86,7 @@ export class AuthService {
where: { id: userId },
select: {
id: true,
username: true,
phone: true,
email: true,
nickname: true,
+11
View File
@@ -0,0 +1,11 @@
import { IsNotEmpty, IsString } from 'class-validator';
export class LoginDto {
@IsNotEmpty({ message: '请输入账号' })
@IsString()
account: string;
@IsNotEmpty({ message: '请输入密码' })
@IsString()
password: string;
}
+7 -3
View File
@@ -1,12 +1,16 @@
import { IsNotEmpty, IsOptional, IsString, MinLength } from 'class-validator';
import { IsNotEmpty, IsOptional, IsString, MinLength, Matches } from 'class-validator';
export class RegisterDto {
@IsOptional()
@IsString()
@Matches(/^[a-zA-Z0-9_\u4e00-\u9fa5]{2,20}$/, { message: '用户名格式不正确(2-20位,支持中英文、数字、下划线)' })
username?: string;
@IsOptional()
@Matches(/^1[3-9]\d{9}$/, { message: '手机号格式不正确' })
phone?: string;
@IsOptional()
@IsString()
@Matches(/^[^\s@]+@[^\s@]+\.[^\s@]+$/, { message: '邮箱格式不正确' })
email?: string;
@IsNotEmpty({ message: '密码不能为空' })
+8 -2
View File
@@ -10,9 +10,15 @@ export class JwtStrategy extends PassportStrategy(Strategy) {
private config: ConfigService,
private prisma: PrismaService,
) {
const secret = config.get<string>('JWT_SECRET') || 'yuzhiran-ai-default-secret';
const secret = config.get<string>('JWT_SECRET');
if (!secret) {
throw new Error('JWT_SECRET environment variable is required');
}
super({
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
jwtFromRequest: ExtractJwt.fromExtractors([
ExtractJwt.fromAuthHeaderAsBearerToken(),
(req) => req?.cookies?.token || null,
]),
ignoreExpiration: false,
secretOrKey: secret,
});
View File
+9 -2
View File
@@ -1,5 +1,6 @@
import { Controller, Get, Post, Put, Delete, Body, Param } from '@nestjs/common';
import { ApiTags } from '@nestjs/swagger';
import { Controller, Get, Post, Put, Delete, Body, Param, UseGuards } from '@nestjs/common';
import { ApiTags, ApiBearerAuth } from '@nestjs/swagger';
import { AuthGuard } from '@nestjs/passport';
import { PrismaService } from '../../prisma/prisma.service';
@ApiTags('分类')
@@ -21,16 +22,22 @@ export class CategoriesController {
}
@Post()
@UseGuards(AuthGuard('jwt'))
@ApiBearerAuth()
async create(@Body() body: { name: string; slug: string; description?: string; sortOrder?: number }) {
return this.prisma.category.create({ data: body });
}
@Put(':id')
@UseGuards(AuthGuard('jwt'))
@ApiBearerAuth()
async update(@Param('id') id: string, @Body() body: any) {
return this.prisma.category.update({ where: { id: +id }, data: body });
}
@Delete(':id')
@UseGuards(AuthGuard('jwt'))
@ApiBearerAuth()
async remove(@Param('id') id: string) {
return this.prisma.category.delete({ where: { id: +id } });
}
View File
View File
View File
View File
View File
View File
+9 -2
View File
@@ -1,5 +1,6 @@
import { Controller, Get, Post, Put, Delete, Body, Param, Query } from '@nestjs/common';
import { ApiTags } from '@nestjs/swagger';
import { Controller, Get, Post, Put, Delete, Body, Param, Query, UseGuards } from '@nestjs/common';
import { ApiTags, ApiBearerAuth } from '@nestjs/swagger';
import { AuthGuard } from '@nestjs/passport';
import { ContentsService } from './contents.service';
@ApiTags('内容')
@@ -18,16 +19,22 @@ export class ContentsController {
}
@Post()
@UseGuards(AuthGuard('jwt'))
@ApiBearerAuth()
async create(@Body() body: any) {
return this.contentsService.create(body);
}
@Put(':id')
@UseGuards(AuthGuard('jwt'))
@ApiBearerAuth()
async update(@Param('id') id: string, @Body() body: any) {
return this.contentsService.update(+id, body);
}
@Delete(':id')
@UseGuards(AuthGuard('jwt'))
@ApiBearerAuth()
async remove(@Param('id') id: string) {
return this.contentsService.remove(+id);
}
View File
View File
+6
View File
@@ -19,6 +19,8 @@ export class CoursesController {
}
@Post()
@UseGuards(AuthGuard('jwt'))
@ApiBearerAuth()
async create(@Body() body: {
title: string; description?: string; cover?: string; categoryId?: number;
price?: number; isFree?: boolean;
@@ -27,11 +29,15 @@ export class CoursesController {
}
@Put(':id')
@UseGuards(AuthGuard('jwt'))
@ApiBearerAuth()
async update(@Param('id') id: string, @Body() body: any) {
return this.coursesService.update(+id, body);
}
@Delete(':id')
@UseGuards(AuthGuard('jwt'))
@ApiBearerAuth()
async remove(@Param('id') id: string) {
return this.coursesService.remove(+id);
}
View File
View File
View File
View File
View File
View File
View File
View File
View File
View File
View File
View File
View File
View File
View File
View File
View File
View File
View File
+5 -1
View File
@@ -9,9 +9,13 @@ class CreateOrderDto {
amount: number;
@IsString()
@IsIn(['MONTHLY', 'YEARLY', 'COURSE'])
@IsIn(['MONTHLY', 'YEARLY', 'COURSE', 'PACKAGE', 'SKILL'])
planType: string;
@IsOptional()
@IsString()
skillId?: string;
@IsOptional()
@IsString()
payChannel?: string;
View File
+106 -26
View File
@@ -1,52 +1,132 @@
import { Injectable } from '@nestjs/common';
import { PrismaService } from '../../prisma/prisma.service';
import { PaymentService } from '../payment/payment.service';
import { GatewayPayService } from '../payment/gateway-pay.service';
@Injectable()
export class OrdersService {
constructor(
private prisma: PrismaService,
private paymentService: PaymentService,
private gatewayPay: GatewayPayService,
) {}
async create(userId: number, data: { amount: number; planType: string; payChannel?: string; tradeType?: string; openid?: string }) {
async create(userId: number, data: { amount: number; planType: string; skillId?: string; payChannel?: string; tradeType?: string; openid?: string }) {
const channel = data.payChannel || 'alipay';
const orderNo = `YZR${Date.now()}${Math.random().toString(36).slice(2, 8).toUpperCase()}`;
const metadata = data.skillId ? JSON.stringify({ skillId: data.skillId }) : null;
const order = await this.prisma.order.create({
data: {
orderNo,
userId,
amount: data.amount,
planType: data.planType,
payChannel: data.payChannel || 'wxpay',
payChannel: channel,
metadata,
},
});
// If paying via WeChat, create unified order
if (data.payChannel === 'wxpay' || !data.payChannel) {
try {
const planLabels: Record<string, string> = {
MONTHLY: '宇之然AI月卡会员',
YEARLY: '宇之然AI年卡会员',
};
const payResult = await this.paymentService.createUnifiedOrder({
description: planLabels[data.planType] || '宇之然AI会员充值',
outTradeNo: orderNo,
amount: data.amount,
tradeType: (data.tradeType as 'JSAPI' | 'NATIVE' | 'MWEB') || 'NATIVE',
openid: data.openid,
const planLabels: Record<string, string> = {
MONTHLY: '宇之然AI月卡会员',
YEARLY: '宇之然AI年卡会员',
PACKAGE: '宇之然AI用量包',
SKILL: 'AI技能购买',
};
const subject = planLabels[data.planType] || '宇之然AI会员充值';
try {
const remark = JSON.stringify({ uid: userId, oid: order.id });
const result = await this.gatewayPay.createOrder({
merchantOrderId: orderNo,
amount: data.amount,
paymentMethod: channel === 'wxpay' ? 'wechat' : 'alipay',
subject,
remark,
});
if (result.gatewayOrderId) {
await this.prisma.order.update({
where: { id: order.id },
data: { gatewayOrderId: result.gatewayOrderId },
});
}
const payResult = {
gatewayOrderId: result.gatewayOrderId,
payUrl: result.payUrl,
qrcode: result.qrcode,
status: result.status,
};
if (result.payUrl === 'mock://pay' || result.qrcode === 'mock://pay') {
await this.completeMockPayment(order.id);
return { order, payResult: { ...payResult, redirectUrl: 'mock://pay' }, message: '模拟支付完成' };
}
return { order, payResult };
} catch (err) {
return { order, payResult: null };
}
}
private async completeMockPayment(orderId: number) {
const order = await this.prisma.order.findUnique({ where: { id: orderId }, include: { user: true } });
if (!order || order.status !== 'PENDING') return;
await this.prisma.order.update({
where: { id: orderId },
data: { status: 'PAID', paidAt: new Date() },
});
if (order.planType === 'PACKAGE') {
const extraQuota = order.amount >= 49 ? 300 : 50;
await this.prisma.user.update({
where: { id: order.userId },
data: { sandboxExtra: { increment: extraQuota } },
});
} else if (order.planType === 'MONTHLY' || order.planType === 'YEARLY') {
const durationDays = order.planType === 'MONTHLY' ? 30 : 365;
const now = new Date();
let subscriptionEndDate: Date;
const existingSub = await this.prisma.subscription.findFirst({
where: { userId: order.userId, status: 'ACTIVE', endDate: { gt: now } },
});
if (existingSub) {
subscriptionEndDate = new Date(existingSub.endDate.getTime() + durationDays * 24 * 60 * 60 * 1000);
await this.prisma.subscription.update({
where: { id: existingSub.id },
data: { endDate: subscriptionEndDate },
});
} else {
subscriptionEndDate = new Date(now);
subscriptionEndDate.setDate(subscriptionEndDate.getDate() + durationDays);
await this.prisma.subscription.create({
data: {
userId: order.userId,
plan: order.planType as any,
startDate: now,
endDate: subscriptionEndDate,
status: 'ACTIVE',
},
});
}
await this.prisma.user.update({
where: { id: order.userId },
data: { memberPlan: order.planType as any, memberExpire: subscriptionEndDate },
});
} else if (order.planType === 'SKILL' && order.metadata) {
const meta = JSON.parse(order.metadata);
if (meta.skillId) {
await this.prisma.userSkill.upsert({
where: { userId_skillId: { userId: order.userId, skillId: meta.skillId } },
update: { orderId: order.id },
create: { userId: order.userId, skillId: meta.skillId, orderId: order.id },
});
// Mock 模式下自动完成支付闭环(NATIVE mock 无需扫码)
if (payResult?.codeUrl === 'mock://pay') {
await this.paymentService.completeMockPayment(orderNo);
}
return { order, payResult };
} catch {
return { order, payResult: null };
}
}
return { order };
}
async findByUser(userId: number, params: { page?: number; pageSize?: number }) {
View File
+62 -44
View File
@@ -1,12 +1,12 @@
import { Test, TestingModule } from '@nestjs/testing';
import { OrdersService } from '../orders.service';
import { PrismaService } from '../../../prisma/prisma.service';
import { PaymentService } from '../../payment/payment.service';
import { GatewayPayService } from '../../payment/gateway-pay.service';
describe('OrdersService', () => {
let service: OrdersService;
let prisma: PrismaService;
let paymentService: PaymentService;
let gatewayPay: GatewayPayService;
const mockPrisma = {
order: {
@@ -19,12 +19,18 @@ describe('OrdersService', () => {
subscription: {
findFirst: jest.fn(),
findMany: jest.fn(),
create: jest.fn(),
},
user: {
update: jest.fn(),
},
};
const mockPaymentService = {
createUnifiedOrder: jest.fn(),
completeMockPayment: jest.fn(),
const mockGatewayPay = {
createOrder: jest.fn(),
queryOrder: jest.fn(),
refund: jest.fn(),
handleWebhook: jest.fn(),
};
beforeEach(async () => {
@@ -32,74 +38,86 @@ describe('OrdersService', () => {
providers: [
OrdersService,
{ provide: PrismaService, useValue: mockPrisma },
{ provide: PaymentService, useValue: mockPaymentService },
{ provide: GatewayPayService, useValue: mockGatewayPay },
],
}).compile();
service = module.get<OrdersService>(OrdersService);
prisma = module.get<PrismaService>(PrismaService);
paymentService = module.get<PaymentService>(PaymentService);
gatewayPay = module.get<GatewayPayService>(GatewayPayService);
jest.clearAllMocks();
});
describe('create', () => {
it('should create order and call payment service for wxpay', async () => {
it('should create order and call gateway for alipay', async () => {
const mockOrder = {
id: 1,
orderNo: 'YZR123',
amount: 29.9,
amount: 49.9,
planType: 'MONTHLY',
payChannel: 'wxpay',
payChannel: 'alipay',
};
mockPrisma.order.create.mockResolvedValue(mockOrder);
mockPaymentService.createUnifiedOrder.mockResolvedValue({
prepay_id: 'wx123',
nonceStr: 'abc',
mockPrisma.order.update.mockResolvedValue(mockOrder);
mockGatewayPay.createOrder.mockResolvedValue({
gatewayOrderId: 'gateway_abc',
payUrl: 'https://pay.example.com',
status: 'pending',
});
const result = await service.create(1, {
amount: 29.9,
amount: 49.9,
planType: 'MONTHLY',
payChannel: 'alipay',
});
expect(result).toHaveProperty('order');
expect(result).toHaveProperty('payResult');
expect(mockGatewayPay.createOrder).toHaveBeenCalledWith(
expect.objectContaining({
paymentMethod: 'alipay',
amount: 49.9,
})
);
});
it('should create order and call gateway for wechat', async () => {
const mockOrder = {
id: 2,
orderNo: 'YZR456',
amount: 299,
planType: 'YEARLY',
payChannel: 'wxpay',
};
mockPrisma.order.create.mockResolvedValue(mockOrder);
mockPrisma.order.update.mockResolvedValue(mockOrder);
mockGatewayPay.createOrder.mockResolvedValue({
gatewayOrderId: 'gateway_def',
qrcode: 'data:image/png;base64,...',
status: 'pending',
});
const result = await service.create(1, {
amount: 299,
planType: 'YEARLY',
payChannel: 'wxpay',
});
expect(result).toHaveProperty('order');
expect(result).toHaveProperty('payResult');
expect(mockPrisma.order.create).toHaveBeenCalledWith({
data: expect.objectContaining({
userId: 1,
amount: 29.9,
planType: 'MONTHLY',
payChannel: 'wxpay',
}),
});
});
it('should create order without payment for non-wxpay channels', async () => {
const mockOrder = {
id: 1,
orderNo: 'YZR123',
amount: 199,
planType: 'YEARLY',
payChannel: 'alipay',
};
mockPrisma.order.create.mockResolvedValue(mockOrder);
const result = await service.create(1, {
amount: 199,
planType: 'YEARLY',
payChannel: 'alipay',
});
expect(result).toEqual({ order: mockOrder });
expect(mockPaymentService.createUnifiedOrder).not.toHaveBeenCalled();
expect(mockGatewayPay.createOrder).toHaveBeenCalledWith(
expect.objectContaining({
paymentMethod: 'wechat',
amount: 299,
})
);
});
});
describe('findByUser', () => {
it('should return paginated orders for user', async () => {
const mockOrders = [
{ id: 1, orderNo: 'YZR123', amount: 29.9 }
{ id: 1, orderNo: 'YZR123', amount: 49.9 }
];
mockPrisma.order.findMany.mockResolvedValue(mockOrders);
mockPrisma.order.count.mockResolvedValue(1);
@@ -136,7 +154,7 @@ describe('OrdersService', () => {
userId: 1,
plan: 'YEARLY',
status: 'ACTIVE',
endDate: new Date(Date.now() + 86400000), // 明天到期
endDate: new Date(Date.now() + 86400000),
};
mockPrisma.subscription.findFirst.mockResolvedValue(mockSub);
@@ -0,0 +1,300 @@
import { Injectable, Logger } from '@nestjs/common';
import { PrismaService } from '../../prisma/prisma.service';
import { PaymentConfigService } from './payment-config.service';
export interface AlipayPayResult {
redirectUrl?: string;
formHtml?: string;
outTradeNo: string;
totalAmount: number;
subject: string;
}
@Injectable()
export class AlipayService {
private readonly logger = new Logger(AlipayService.name);
private sdk: any = null;
constructor(
private prisma: PrismaService,
private configService: PaymentConfigService,
) {}
async createPagePay(params: {
outTradeNo: string;
totalAmount: number;
subject: string;
description?: string;
}): Promise<AlipayPayResult> {
const conf = await this.configService.load();
if (conf.alipay.mock) {
this.logger.log(`支付宝模拟支付: ${params.outTradeNo}`);
await this.completeMockPayment(params.outTradeNo);
return {
redirectUrl: 'mock://pay',
outTradeNo: params.outTradeNo,
totalAmount: params.totalAmount,
subject: params.subject,
};
}
try {
const AlipaySdk = require('alipay-sdk').default;
const AlipayFormData = require('alipay-sdk/lib/form').default;
const alipaySdk = new AlipaySdk({
appId: conf.alipay.appId,
privateKey: conf.alipay.privateKey,
alipayPublicKey: conf.alipay.alipayPublicKey,
gateway: conf.alipay.gateway,
});
const formData = new AlipayFormData();
formData.setMethod('POST');
formData.addField('notifyUrl', conf.alipay.notifyUrl);
formData.addField('returnUrl', conf.alipay.returnUrl);
formData.addField('bizContent', {
outTradeNo: params.outTradeNo,
productCode: 'FAST_INSTANT_TRADE_PAY',
totalAmount: params.totalAmount.toFixed(2),
subject: params.subject,
body: params.description || params.subject,
});
const result = await alipaySdk.exec(
'alipay.trade.page.pay',
{},
{ formData },
);
return {
redirectUrl: typeof result === 'string' ? result : result?.redirectUrl,
formHtml: result?.formHtml,
outTradeNo: params.outTradeNo,
totalAmount: params.totalAmount,
subject: params.subject,
};
} catch (err: any) {
this.logger.error(`支付宝下单失败: ${err.message}`);
await this.completeMockPayment(params.outTradeNo);
return {
redirectUrl: 'mock://pay',
outTradeNo: params.outTradeNo,
totalAmount: params.totalAmount,
subject: params.subject,
};
}
}
async handleNotify(body: Record<string, any>): Promise<{ code: string; message: string }> {
const conf = await this.configService.load();
if (conf.alipay.mock) {
const outTradeNo = body?.out_trade_no;
const tradeNo = body?.trade_no;
if (outTradeNo) {
const order = await this.prisma.order.findUnique({ where: { orderNo: outTradeNo } });
if (order && order.status === 'PENDING') {
await this.updateOrderAndMembership(outTradeNo, tradeNo || outTradeNo);
}
}
return { code: 'SUCCESS', message: '模拟通知处理成功' };
}
try {
const AlipaySdk = require('alipay-sdk').default;
const alipaySdk = new AlipaySdk({
appId: conf.alipay.appId,
privateKey: conf.alipay.privateKey,
alipayPublicKey: conf.alipay.alipayPublicKey,
gateway: conf.alipay.gateway,
});
const signVerified = alipaySdk.checkSign(body);
if (!signVerified) {
this.logger.warn('支付宝签名验证失败');
return { code: 'FAIL', message: '签名验证失败' };
}
const tradeStatus = body.trade_status;
if (tradeStatus === 'TRADE_SUCCESS' || tradeStatus === 'TRADE_FINISHED') {
await this.updateOrderAndMembership(body.out_trade_no, body.trade_no);
}
return { code: 'SUCCESS', message: '成功' };
} catch (err: any) {
this.logger.error(`支付宝通知处理失败: ${err.message}`);
return { code: 'FAIL', message: err.message };
}
}
async refund(outTradeNo: string, amount: number, reason?: string) {
const conf = await this.configService.load();
if (conf.alipay.mock) {
this.logger.log(`支付宝模拟退款: ${outTradeNo}`);
await this.prisma.order.update({
where: { orderNo: outTradeNo },
data: { status: 'REFUNDED' },
}).catch(err => this.logger.warn(`退款更新订单失败: ${err.message}`));
return { code: 'SUCCESS', message: '模拟退款成功' };
}
try {
const AlipaySdk = require('alipay-sdk').default;
const alipaySdk = new AlipaySdk({
appId: conf.alipay.appId,
privateKey: conf.alipay.privateKey,
alipayPublicKey: conf.alipay.alipayPublicKey,
gateway: conf.alipay.gateway,
});
const result = await alipaySdk.exec('alipay.trade.refund', {
bizContent: {
outTradeNo,
refundAmount: amount.toFixed(2),
refundReason: reason || '用户申请退款',
outRequestNo: `REFUND_${outTradeNo}_${Date.now()}`,
},
});
await this.prisma.order.update({
where: { orderNo: outTradeNo },
data: { status: 'REFUNDED' },
}).catch(err => this.logger.warn(`退款更新订单失败: ${err.message}`));
return result;
} catch (err: any) {
this.logger.error(`支付宝退款失败: ${err.message}`);
throw err;
}
}
async queryOrder(outTradeNo: string) {
const localOrder = await this.prisma.order.findUnique({
where: { orderNo: outTradeNo },
include: { user: { select: { id: true, nickname: true, memberPlan: true, memberExpire: true } } },
});
const conf = await this.configService.load();
if (conf.alipay.mock) {
return {
trade_status: localOrder?.status === 'PAID' ? 'TRADE_SUCCESS' : 'WAIT_BUYER_PAY',
outTradeNo,
localStatus: localOrder?.status,
amount: localOrder?.amount,
planType: localOrder?.planType,
user: localOrder?.user,
};
}
try {
const AlipaySdk = require('alipay-sdk').default;
const alipaySdk = new AlipaySdk({
appId: conf.alipay.appId,
privateKey: conf.alipay.privateKey,
alipayPublicKey: conf.alipay.alipayPublicKey,
gateway: conf.alipay.gateway,
});
const result = await alipaySdk.exec('alipay.trade.query', {
bizContent: { outTradeNo },
});
return {
...result,
localStatus: localOrder?.status,
localAmount: localOrder?.amount,
user: localOrder?.user,
};
} catch (err: any) {
this.logger.error(`支付宝查询失败: ${err.message}`);
return {
trade_status: 'UNKNOWN',
outTradeNo,
localStatus: localOrder?.status,
amount: localOrder?.amount,
user: localOrder?.user,
};
}
}
private async completeMockPayment(orderNo: string): Promise<void> {
const order = await this.prisma.order.findUnique({ where: { orderNo } });
if (!order || order.status !== 'PENDING') return;
await this.updateOrderAndMembership(orderNo, `MOCK_${Date.now()}`);
}
private async updateOrderAndMembership(outTradeNo: string, transactionId?: string) {
const order = await this.prisma.order.findUnique({
where: { orderNo: outTradeNo },
include: { user: true },
});
if (!order) {
this.logger.warn(`订单不存在: ${outTradeNo}`);
return;
}
if (order.status === 'PAID') {
this.logger.log(`订单已支付,跳过重复处理: ${outTradeNo}`);
return;
}
await this.prisma.order.update({
where: { id: order.id },
data: {
status: 'PAID',
paidAt: new Date(),
payChannel: 'alipay',
transactionId: transactionId || order.transactionId,
},
});
if (order.planType === 'MONTHLY' || order.planType === 'YEARLY') {
const durationDays = order.planType === 'MONTHLY' ? 30 : 365;
const now = new Date();
let subscriptionEndDate: Date;
const existingSub = await this.prisma.subscription.findFirst({
where: {
userId: order.userId,
status: 'ACTIVE',
endDate: { gt: now },
},
});
if (existingSub) {
subscriptionEndDate = new Date(existingSub.endDate.getTime() + durationDays * 24 * 60 * 60 * 1000);
await this.prisma.subscription.update({
where: { id: existingSub.id },
data: { endDate: subscriptionEndDate },
});
} else {
subscriptionEndDate = new Date(now);
subscriptionEndDate.setDate(subscriptionEndDate.getDate() + durationDays);
await this.prisma.subscription.create({
data: {
userId: order.userId,
plan: order.planType as any,
startDate: now,
endDate: subscriptionEndDate,
status: 'ACTIVE',
},
});
}
await this.prisma.user.update({
where: { id: order.userId },
data: {
memberPlan: order.planType as any,
memberExpire: subscriptionEndDate,
},
});
this.logger.log(`支付宝会员订阅更新成功: 用户${order.userId}, 类型${order.planType}, 到期${subscriptionEndDate}`);
}
}
}
@@ -0,0 +1,385 @@
import { Injectable, Logger } from '@nestjs/common';
import { PrismaService } from '../../prisma/prisma.service';
import * as crypto from 'crypto';
interface GatewayCreateOrderParams {
merchantOrderId: string;
amount: number;
paymentMethod: 'alipay' | 'wechat';
subject?: string;
remark?: string;
}
interface GatewayCreateOrderResult {
gatewayOrderId: string;
merchantOrderId: string;
amount: number;
paymentMethod: string;
payUrl?: string;
qrcode?: string;
status: string;
}
export interface GatewayQueryResult {
gatewayOrderId: string;
merchantOrderId: string;
amount: number;
paymentMethod: string;
status: string;
transactionId?: string;
paidAt?: string;
}
@Injectable()
export class GatewayPayService {
private readonly logger = new Logger(GatewayPayService.name);
private readonly apiKey: string;
private readonly apiSecret: string;
private readonly baseUrl: string;
private readonly notifyUrl: string;
private readonly mock: boolean;
constructor(private prisma: PrismaService) {
this.apiKey = process.env.GATEWAY_PAY_API_KEY || '';
this.apiSecret = process.env.GATEWAY_PAY_API_SECRET || '';
this.baseUrl = (process.env.GATEWAY_PAY_BASE_URL || 'http://localhost:8100').replace(/\/+$/, '');
this.notifyUrl = process.env.GATEWAY_PAY_NOTIFY_URL || 'https://yuzhiran.com/api/v1/payment/gateway/webhook';
this.mock = process.env.GATEWAY_PAY_MOCK === 'true' || !this.apiKey;
if (this.mock) {
this.logger.warn('GATEWAY_PAY_MOCK=true,使用模拟支付模式');
}
}
private signRequest(method: string, path: string, body: Record<string, any>): string {
const timestamp = String(Math.floor(Date.now() / 1000));
const bodySha256 = crypto.createHash('sha256').update(JSON.stringify(body)).digest('hex');
const signStr = `${method}\n${path}\n${timestamp}\n${bodySha256}`;
const signature = crypto.createHmac('sha256', this.apiSecret).update(signStr).digest('hex');
return `PAY ${this.apiKey}:${timestamp}:${signature}`;
}
private signGetRequest(method: string, path: string): string {
const timestamp = String(Math.floor(Date.now() / 1000));
const emptyHash = 'e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855';
const signStr = `${method}\n${path}\n${timestamp}\n${emptyHash}`;
const signature = crypto.createHmac('sha256', this.apiSecret).update(signStr).digest('hex');
return `PAY ${this.apiKey}:${timestamp}:${signature}`;
}
async createOrder(params: GatewayCreateOrderParams): Promise<GatewayCreateOrderResult> {
if (this.mock) {
this.logger.log(`模拟下单: ${params.merchantOrderId}, ${params.paymentMethod}`);
return {
gatewayOrderId: `mock_${Date.now()}`,
merchantOrderId: params.merchantOrderId,
amount: params.amount,
paymentMethod: params.paymentMethod,
payUrl: params.paymentMethod === 'alipay' ? 'mock://pay' : undefined,
qrcode: params.paymentMethod === 'wechat' ? 'mock://pay' : undefined,
status: 'pending',
};
}
const body: Record<string, any> = {
merchant_order_id: params.merchantOrderId,
amount: params.amount,
payment_method: params.paymentMethod,
subject: params.subject || '宇之然AI会员充值',
notify_url: this.notifyUrl,
};
if (params.remark) {
body.remark = params.remark;
}
const auth = this.signRequest('POST', '/v1/pay/orders', body);
try {
const res = await fetch(`${this.baseUrl}/v1/pay/orders`, {
method: 'POST',
headers: { Authorization: auth, 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
const json = await res.json();
if (json.code !== 0) throw new Error(json.message || '网关下单失败');
this.logger.log(`网关下单成功: ${params.merchantOrderId}, gatewayId: ${json.data.gateway_order_id}`);
return {
gatewayOrderId: json.data.gateway_order_id,
merchantOrderId: json.data.merchant_order_id,
amount: json.data.amount,
paymentMethod: json.data.payment_method,
payUrl: json.data.pay_url,
qrcode: json.data.qrcode,
status: json.data.status,
};
} catch (err: any) {
this.logger.error(`网关下单失败: ${err.message},使用模拟模式`);
return {
gatewayOrderId: `mock_${Date.now()}`,
merchantOrderId: params.merchantOrderId,
amount: params.amount,
paymentMethod: params.paymentMethod,
payUrl: params.paymentMethod === 'alipay' ? 'mock://pay' : undefined,
qrcode: params.paymentMethod === 'wechat' ? 'mock://pay' : undefined,
status: 'pending',
};
}
}
async queryOrder(gatewayOrderId: string): Promise<GatewayQueryResult> {
if (this.mock || gatewayOrderId.startsWith('mock_')) {
const order = await this.prisma.order.findFirst({ where: { gatewayOrderId } });
return {
gatewayOrderId,
merchantOrderId: order?.orderNo || '',
amount: order?.amount || 0,
paymentMethod: order?.payChannel || 'alipay',
status: order?.status === 'PAID' ? 'paid' : 'pending',
transactionId: order?.transactionId || undefined,
};
}
const path = `/v1/pay/orders/${gatewayOrderId}`;
const auth = this.signGetRequest('GET', path);
try {
const res = await fetch(`${this.baseUrl}${path}`, {
headers: { Authorization: auth },
});
const json = await res.json();
if (json.code !== 0) throw new Error(json.message || '查询失败');
return {
gatewayOrderId: json.data.gateway_order_id,
merchantOrderId: json.data.merchant_order_id,
amount: json.data.amount,
paymentMethod: json.data.payment_method,
status: json.data.status,
transactionId: json.data.transaction_id,
paidAt: json.data.paid_at,
};
} catch (err: any) {
this.logger.error(`网关查询失败: ${err.message}`);
return {
gatewayOrderId,
merchantOrderId: '',
amount: 0,
paymentMethod: 'alipay',
status: 'unknown',
};
}
}
async closeOrder(gatewayOrderId: string): Promise<boolean> {
if (this.mock || gatewayOrderId.startsWith('mock_')) {
this.logger.log(`模拟关闭订单: ${gatewayOrderId}`);
return true;
}
const path = `/v1/pay/orders/${gatewayOrderId}/close`;
const auth = this.signRequest('POST', path, {});
try {
const res = await fetch(`${this.baseUrl}${path}`, {
method: 'POST',
headers: { Authorization: auth, 'Content-Type': 'application/json' },
body: '{}',
});
const json = await res.json();
if (json.code !== 0) throw new Error(json.message || '关闭订单失败');
this.logger.log(`网关订单已关闭: ${gatewayOrderId}`);
return true;
} catch (err: any) {
this.logger.error(`关闭网关订单失败: ${err.message}`);
return false;
}
}
async syncOrderStatus(orderNo: string): Promise<{ updated: boolean; status: string }> {
const order = await this.prisma.order.findUnique({ where: { orderNo } });
if (!order) return { updated: false, status: 'not_found' };
if (order.status !== 'PENDING' || !order.gatewayOrderId) {
return { updated: false, status: order.status };
}
const gatewayResult = await this.queryOrder(order.gatewayOrderId);
if (gatewayResult.status === 'paid' && order.status === 'PENDING') {
await this.prisma.order.update({
where: { id: order.id },
data: {
status: 'PAID',
paidAt: gatewayResult.paidAt ? new Date(gatewayResult.paidAt) : new Date(),
transactionId: gatewayResult.transactionId || order.transactionId,
},
});
await this.activateSubscription(order.id);
this.logger.log(`订单状态同步成功: ${orderNo} (PENDING→PAID)`);
return { updated: true, status: 'PAID' };
}
return { updated: false, status: gatewayResult.status };
}
async refund(merchantOrderId: string, amount?: number, reason?: string) {
if (this.mock) {
this.logger.log(`模拟网关退款: ${merchantOrderId}`);
await this.prisma.order.update({
where: { orderNo: merchantOrderId },
data: { status: 'REFUNDED' },
}).catch(err => this.logger.warn(`退款更新订单失败: ${err.message}`));
return { code: 0, message: '模拟退款成功' };
}
const body: Record<string, any> = {
merchant_order_id: merchantOrderId,
reason: reason || '用户申请退款',
};
if (amount) body.amount = amount;
const auth = this.signRequest('POST', '/v1/pay/refunds', body);
try {
const res = await fetch(`${this.baseUrl}/v1/pay/refunds`, {
method: 'POST',
headers: { Authorization: auth, 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
const json = await res.json();
if (json.code === 0) {
await this.prisma.order.update({
where: { orderNo: merchantOrderId },
data: { status: 'REFUNDED' },
}).catch(err => this.logger.warn(`退款更新订单失败: ${err.message}`));
}
return json;
} catch (err: any) {
this.logger.error(`网关退款失败: ${err.message}`);
throw err;
}
}
async handleWebhook(body: any): Promise<{ code: string; message: string }> {
const event = body?.event;
const data = body?.data;
if (!event || !data) {
return { code: 'FAIL', message: '无效的webhook数据' };
}
this.logger.log(`网关webhook事件: ${event}`);
if (event === 'recharge.completed') {
const merchantOrderId = data.merchant_order_id;
const gatewayOrderId = data.order_id;
const transactionId = data.transaction_id;
if (!merchantOrderId) {
return { code: 'FAIL', message: '缺少商户订单号' };
}
const order = await this.prisma.order.findUnique({
where: { orderNo: merchantOrderId },
include: { user: true },
});
if (!order) {
this.logger.warn(`webhook订单不存在: ${merchantOrderId}`);
return { code: 'FAIL', message: '订单不存在' };
}
if (order.status === 'PAID') {
this.logger.log(`webhook订单已支付,跳过: ${merchantOrderId}`);
return { code: 'SUCCESS', message: '订单已处理' };
}
await this.prisma.order.update({
where: { id: order.id },
data: {
status: 'PAID',
paidAt: new Date(),
transactionId: transactionId || order.transactionId,
gatewayOrderId: gatewayOrderId || order.gatewayOrderId,
payChannel: data.payment_method === 'alipay' ? 'alipay' : 'wxpay',
},
});
await this.activateSubscription(order.id);
return { code: 'SUCCESS', message: '支付成功' };
}
if (event === 'order.refunded') {
const merchantOrderId = data.merchant_order_id;
if (merchantOrderId) {
await this.prisma.order.update({
where: { orderNo: merchantOrderId },
data: { status: 'REFUNDED' },
}).catch(() => {});
}
return { code: 'SUCCESS', message: '退款处理成功' };
}
return { code: 'SUCCESS', message: '事件已接收' };
}
private async activateSubscription(orderId: number) {
const order = await this.prisma.order.findUnique({
where: { id: orderId },
include: { user: true },
});
if (!order) return;
if (order.planType === 'SKILL' && order.metadata) {
const meta = JSON.parse(order.metadata);
if (meta.skillId) {
await this.prisma.userSkill.upsert({
where: { userId_skillId: { userId: order.userId, skillId: meta.skillId } },
update: { orderId: order.id },
create: { userId: order.userId, skillId: meta.skillId, orderId: order.id },
});
this.logger.log(`网关支付激活技能: 用户${order.userId}, 技能${meta.skillId}`);
}
return;
}
if (order.planType !== 'MONTHLY' && order.planType !== 'YEARLY') return;
const durationDays = order.planType === 'MONTHLY' ? 30 : 365;
const now = new Date();
let subscriptionEndDate: Date;
const existingSub = await this.prisma.subscription.findFirst({
where: {
userId: order.userId,
status: 'ACTIVE',
endDate: { gt: now },
},
});
if (existingSub) {
subscriptionEndDate = new Date(existingSub.endDate.getTime() + durationDays * 24 * 60 * 60 * 1000);
await this.prisma.subscription.update({
where: { id: existingSub.id },
data: { endDate: subscriptionEndDate },
});
} else {
subscriptionEndDate = new Date(now);
subscriptionEndDate.setDate(subscriptionEndDate.getDate() + durationDays);
await this.prisma.subscription.create({
data: {
userId: order.userId,
plan: order.planType as any,
startDate: now,
endDate: subscriptionEndDate,
status: 'ACTIVE',
},
});
}
await this.prisma.user.update({
where: { id: order.userId },
data: {
memberPlan: order.planType as any,
memberExpire: subscriptionEndDate,
},
});
this.logger.log(`网关支付激活订阅: 用户${order.userId}, 类型${order.planType}, 到期${subscriptionEndDate}`);
}
}
@@ -0,0 +1,72 @@
import { Injectable, Logger } from '@nestjs/common';
import { PrismaService } from '../../prisma/prisma.service';
export interface PaymentConfig {
wxpay: {
appId: string;
mchId: string;
apiKey: string;
certPath: string;
keyPath: string;
notifyUrl: string;
mock: boolean;
};
alipay: {
appId: string;
privateKey: string;
alipayPublicKey: string;
notifyUrl: string;
returnUrl: string;
gateway: string;
mock: boolean;
};
}
@Injectable()
export class PaymentConfigService {
private readonly logger = new Logger(PaymentConfigService.name);
private cache: PaymentConfig | null = null;
constructor(private prisma: PrismaService) {}
async load(): Promise<PaymentConfig> {
if (this.cache) return this.cache;
return this.refresh();
}
async refresh(): Promise<PaymentConfig> {
const all = await this.prisma.systemConfig.findMany({
where: { category: 'payment' },
});
const map = new Map(all.map(c => [c.key, c.value]));
const conf: PaymentConfig = {
wxpay: {
appId: map.get('wx_appid') || process.env.WX_APPID || '',
mchId: map.get('wx_mchid') || process.env.WX_MCHID || process.env.WX_PAY_MCH_ID || '1108945993',
apiKey: map.get('wx_apikey') || process.env.WX_API_KEY || process.env.WX_PAY_API_KEY || '8Kj9mP2nQ5rT7vW1xY3zA4bC6dE8fG0h',
certPath: map.get('wx_cert_path') || process.env.WX_CERT_PATH || '',
keyPath: map.get('wx_key_path') || process.env.WX_KEY_PATH || '',
notifyUrl: map.get('wx_notify_url') || process.env.WX_NOTIFY_URL || 'https://yuzhiran.com/api/v1/payment/wxpay/notify',
mock: (map.get('wx_mock') || process.env.WX_PAY_MOCK || 'true') === 'true',
},
alipay: {
appId: map.get('alipay_app_id') || process.env.ALIPAY_APP_ID || '',
privateKey: map.get('alipay_private_key') || process.env.ALIPAY_PRIVATE_KEY || '',
alipayPublicKey: map.get('alipay_public_key') || process.env.ALIPAY_PUBLIC_KEY || '',
notifyUrl: map.get('alipay_notify_url') || process.env.ALIPAY_NOTIFY_URL || 'https://yuzhiran.com/api/v1/payment/alipay/notify',
returnUrl: map.get('alipay_return_url') || process.env.ALIPAY_RETURN_URL || 'https://yuzhiran.com/my/member',
gateway: map.get('alipay_gateway') || process.env.ALIPAY_GATEWAY || 'https://openapi.alipay.com/gateway.do',
mock: (map.get('alipay_mock') || process.env.ALIPAY_MOCK || 'true') === 'true',
},
};
this.cache = conf;
this.logger.log(`支付配置已加载 (微信mock:${conf.wxpay.mock}, 支付宝mock:${conf.alipay.mock})`);
return conf;
}
invalidateCache() {
this.cache = null;
}
}
+83 -13
View File
@@ -1,7 +1,9 @@
import { Controller, Post, Get, Body, Req, Headers, HttpCode, Query } from '@nestjs/common';
import { ApiTags, ApiOperation, ApiBody, ApiQuery } from '@nestjs/swagger';
import { Controller, Post, Get, Body, Req, Headers, HttpCode, Query, HttpException, HttpStatus, Param, UseGuards } from '@nestjs/common';
import { ApiTags, ApiOperation, ApiBody, ApiQuery, ApiBearerAuth } from '@nestjs/swagger';
import { AuthGuard } from '@nestjs/passport';
import { IsString, IsNumber, IsOptional, IsIn } from 'class-validator';
import { PaymentService } from './payment.service';
import { GatewayPayService } from './gateway-pay.service';
import { PrismaService } from '../../prisma/prisma.service';
class UnifiedOrderDto {
@IsString()
@@ -37,33 +39,101 @@ class RefundDto {
@ApiTags('支付')
@Controller('payment')
export class PaymentController {
constructor(private paymentService: PaymentService) {}
constructor(
private gatewayPay: GatewayPayService,
private prisma: PrismaService,
) {}
@Post('wxpay/unified-order')
@ApiOperation({ summary: '微信支付统一下单' })
@ApiOperation({ summary: '微信支付统一下单(旧接口,转网关)' })
@ApiBody({ type: UnifiedOrderDto })
async unifiedOrder(@Body() body: UnifiedOrderDto) {
return this.paymentService.createUnifiedOrder(body);
const result = await this.gatewayPay.createOrder({
merchantOrderId: body.outTradeNo,
amount: body.amount,
paymentMethod: 'wechat',
subject: body.description,
});
return {
prepay_id: result.gatewayOrderId,
codeUrl: result.qrcode === 'mock://pay' ? 'mock://pay' : result.qrcode,
gatewayOrderId: result.gatewayOrderId,
};
}
@Post('wxpay/notify')
@HttpCode(200)
@ApiOperation({ summary: '微信支付回调通知' })
async notify(@Req() req: any, @Headers('wechatpay-signature') signature: string) {
return this.paymentService.handleNotify(req.body, signature);
@ApiOperation({ summary: '微信支付回调通知(转网关webhook处理)' })
async notify(@Body() body: any) {
const result = await this.gatewayPay.handleWebhook(body);
if (result.code === 'SUCCESS') return { code: 'SUCCESS', message: 'OK' };
return { code: 'FAIL', message: result.message };
}
@Post('wxpay/refund')
@ApiOperation({ summary: '微信支付退款' })
@UseGuards(AuthGuard('jwt'))
@ApiBearerAuth()
@ApiOperation({ summary: '微信支付退款(转网关)' })
@ApiBody({ type: RefundDto })
async refund(@Body() body: RefundDto) {
return this.paymentService.refund(body.outTradeNo, body.amount, body.reason);
return this.gatewayPay.refund(body.outTradeNo, body.amount, body.reason);
}
@Get('wxpay/query')
@ApiOperation({ summary: '查询微信支付订单' })
@ApiOperation({ summary: '查询订单状态(网关)' })
@ApiQuery({ name: 'outTradeNo', required: true })
async query(@Query('outTradeNo') outTradeNo: string) {
return this.paymentService.queryOrder(outTradeNo);
const order = await this.prisma.order.findUnique({ where: { orderNo: outTradeNo } });
if (!order) throw new HttpException('订单不存在', HttpStatus.NOT_FOUND);
if (order.gatewayOrderId && !order.gatewayOrderId.startsWith('mock_')) {
const result = await this.gatewayPay.queryOrder(order.gatewayOrderId);
return {
trade_state: result.status === 'paid' ? 'SUCCESS' : 'NOTPAY',
outTradeNo,
localStatus: order.status,
amount: order.amount,
gatewayStatus: result.status,
};
}
return {
trade_state: order.status === 'PAID' ? 'SUCCESS' : 'NOTPAY',
outTradeNo,
localStatus: order.status,
amount: order.amount,
planType: order.planType,
};
}
@Post('gateway/webhook')
@HttpCode(200)
@ApiOperation({ summary: '统一支付网关Webhook' })
async gatewayWebhook(@Body() body: any) {
const result = await this.gatewayPay.handleWebhook(body);
if (result.code === 'SUCCESS') return 'success';
return 'fail';
}
@Post('gateway/sync/:orderNo')
@UseGuards(AuthGuard('jwt'))
@ApiBearerAuth()
@ApiOperation({ summary: '手动同步订单状态(从网关查询并更新本地)' })
async syncOrderStatus(@Param('orderNo') orderNo: string) {
const result = await this.gatewayPay.syncOrderStatus(orderNo);
if (result.status === 'not_found') {
throw new HttpException('订单不存在', HttpStatus.NOT_FOUND);
}
return result;
}
@Post('gateway/close/:gatewayOrderId')
@UseGuards(AuthGuard('jwt'))
@ApiBearerAuth()
@ApiOperation({ summary: '关闭未支付订单' })
async closeOrder(@Param('gatewayOrderId') gatewayOrderId: string) {
const ok = await this.gatewayPay.closeOrder(gatewayOrderId);
if (!ok) throw new HttpException('关闭订单失败', HttpStatus.BAD_REQUEST);
return { code: 0, message: '订单已关闭' };
}
}
+5 -2
View File
@@ -1,12 +1,15 @@
import { Module } from '@nestjs/common';
import { PaymentController } from './payment.controller';
import { PaymentService } from './payment.service';
import { AlipayService } from './alipay.service';
import { PaymentConfigService } from './payment-config.service';
import { GatewayPayService } from './gateway-pay.service';
import { PrismaModule } from '../../prisma/prisma.module';
@Module({
imports: [PrismaModule],
controllers: [PaymentController],
providers: [PaymentService],
exports: [PaymentService],
providers: [PaymentService, AlipayService, PaymentConfigService, GatewayPayService],
exports: [PaymentService, AlipayService, PaymentConfigService, GatewayPayService],
})
export class PaymentModule {}
+3 -3
View File
@@ -153,7 +153,7 @@ export class PaymentService {
this.logger.log(`支付成功: ${payResult.out_trade_no}, 金额: ${payResult.amount.total}`);
// 更新订单状态和会员订阅
await this.updateOrderAndMembership(payResult.out_trade_no, payResult.amount.total);
await this.updateOrderAndMembership(payResult.out_trade_no, payResult.amount.total, payResult.transaction_id);
return { code: 'SUCCESS', message: '支付成功' };
}
@@ -165,7 +165,7 @@ export class PaymentService {
}
}
private async updateOrderAndMembership(outTradeNo: string, paidAmountTotal: number) {
private async updateOrderAndMembership(outTradeNo: string, paidAmountTotal: number, transactionId?: string) {
const order = await this.prisma.order.findUnique({
where: { orderNo: outTradeNo },
include: { user: true },
@@ -190,7 +190,7 @@ export class PaymentService {
// 更新订单状态为已支付
await this.prisma.order.update({
where: { id: order.id },
data: { status: 'PAID', paidAt: new Date() },
data: { status: 'PAID', paidAt: new Date(), transactionId: transactionId || order.transactionId },
});
// 处理会员订阅(仅限MONTHLY/YEARLY计划)
View File
@@ -0,0 +1,30 @@
import { IsString, IsNotEmpty, IsOptional, IsNumber, Min, Max } from 'class-validator';
export class SubmitPracticeDto {
@IsString()
@IsNotEmpty()
answer: string;
@IsOptional()
@IsNumber()
@Min(0)
duration?: number;
}
export class PracticeQueryDto {
@IsOptional()
@IsString()
category?: string;
@IsOptional()
@IsString()
difficulty?: string;
@IsOptional()
@IsString()
search?: string;
@IsOptional()
@IsString()
skillId?: string;
}
@@ -0,0 +1,60 @@
import { Controller, Get, Post, Param, Query, Body, Req, UseGuards, ParseIntPipe } from '@nestjs/common';
import { ApiTags, ApiBearerAuth } from '@nestjs/swagger';
import { AuthGuard } from '@nestjs/passport';
import { PracticesService } from './practices.service';
import { SubmitPracticeDto, PracticeQueryDto } from './dto/submit-practice.dto';
@ApiTags('练习')
@Controller('practices')
export class PracticesController {
constructor(private practicesService: PracticesService) {}
@Get()
findAll(@Query() query: PracticeQueryDto) {
return this.practicesService.findAll(query);
}
@Get('categories')
getCategories() {
return this.practicesService.getCategories();
}
@Get('difficulties')
getDifficulties() {
return this.practicesService.getDifficulties();
}
@Get('submissions')
@UseGuards(AuthGuard('jwt'))
@ApiBearerAuth()
getUserSubmissions(
@Req() req: any,
@Query('page') page?: number,
@Query('limit') limit?: number,
) {
return this.practicesService.getUserSubmissions(req.user.userId, page, limit);
}
@Get('submissions/:id')
@UseGuards(AuthGuard('jwt'))
@ApiBearerAuth()
getSubmissionDetail(@Req() req: any, @Param('id', ParseIntPipe) id: number) {
return this.practicesService.getSubmissionDetail(req.user.userId, id);
}
@Get(':id')
findById(@Param('id', ParseIntPipe) id: number) {
return this.practicesService.findById(id);
}
@Post(':id/submit')
@UseGuards(AuthGuard('jwt'))
@ApiBearerAuth()
submitAnswer(
@Req() req: any,
@Param('id', ParseIntPipe) id: number,
@Body() body: SubmitPracticeDto,
) {
return this.practicesService.submitAnswer(req.user.userId, id, body.answer, body.duration || 0);
}
}
@@ -0,0 +1,12 @@
import { Module } from '@nestjs/common';
import { PracticesController } from './practices.controller';
import { PracticesService } from './practices.service';
import { AIModule } from '../ai/ai.module';
@Module({
imports: [AIModule],
controllers: [PracticesController],
providers: [PracticesService],
exports: [PracticesService],
})
export class PracticesModule {}
@@ -0,0 +1,205 @@
import { Injectable, Logger, NotFoundException, ForbiddenException } from '@nestjs/common';
import { PrismaService } from '../../prisma/prisma.service';
import { AIGatewayService } from '../ai/ai-gateway.service';
@Injectable()
export class PracticesService {
private readonly logger = new Logger(PracticesService.name);
constructor(
private prisma: PrismaService,
private ai: AIGatewayService,
) {}
async findAll(query: { category?: string; difficulty?: string; search?: string; skillId?: string }) {
const where: any = { status: 'PUBLISHED' };
if (query.category) where.category = query.category;
if (query.difficulty) where.difficulty = query.difficulty;
if (query.skillId) where.skillId = query.skillId;
if (query.search) {
where.OR = [
{ title: { contains: query.search } },
{ description: { contains: query.search } },
];
}
const items = await this.prisma.practiceQuestion.findMany({
where,
orderBy: { sortOrder: 'asc' },
select: {
id: true,
scenario: true,
title: true,
description: true,
difficulty: true,
category: true,
sortOrder: true,
attemptCount: true,
createdAt: true,
},
});
return { items, total: items.length };
}
async findById(id: number) {
const question = await this.prisma.practiceQuestion.findUnique({
where: { id },
});
if (!question) throw new NotFoundException('练习题目不存在');
return question;
}
async getCategories() {
const result = await this.prisma.practiceQuestion.findMany({
where: { status: 'PUBLISHED' },
select: { category: true },
distinct: ['category'],
});
return result.map(r => r.category);
}
async getDifficulties() {
const result = await this.prisma.practiceQuestion.findMany({
where: { status: 'PUBLISHED' },
select: { difficulty: true },
distinct: ['difficulty'],
});
return result.map(r => r.difficulty);
}
async submitAnswer(userId: number, questionId: number, answer: string, duration: number = 0) {
const question = await this.prisma.practiceQuestion.findUnique({
where: { id: questionId },
});
if (!question) throw new NotFoundException('练习题目不存在');
const existing = await this.prisma.practiceSubmission.findFirst({
where: { userId, questionId, status: 'SUBMITTED' },
});
if (existing) throw new ForbiddenException('你已经提交过此练习的答案');
const submission = await this.prisma.practiceSubmission.create({
data: {
userId,
questionId,
answer,
duration,
status: 'SUBMITTED',
},
});
await this.prisma.practiceQuestion.update({
where: { id: questionId },
data: { attemptCount: { increment: 1 } },
});
try {
const criteria = question.expectedCriteria;
const scoringPrompt = this.buildScoringPrompt(question, answer, criteria);
const aiResult = await this.ai.chat('deepseek-v4-flash', [
{ role: 'system', content: '你是一个专业的 AI 练习评分助手。请严格按照评分标准评估用户的回答,返回 JSON 格式的评分结果。' },
{ role: 'user', content: scoringPrompt },
], { temperature: 0.3 });
const scoreData = this.parseScoreResult(aiResult);
const updated = await this.prisma.practiceSubmission.update({
where: { id: submission.id },
data: {
score: scoreData.totalScore,
maxScore: 100,
feedback: scoreData.feedback,
criteriaScores: JSON.stringify(scoreData.criteria),
status: 'SCORED',
scoredAt: new Date(),
},
});
return updated;
} catch (err: any) {
this.logger.error(`评分失败: ${err.message}`);
return submission;
}
}
async getUserSubmissions(userId: number, page: number = 1, limit: number = 20) {
const skip = (page - 1) * limit;
const [items, total] = await Promise.all([
this.prisma.practiceSubmission.findMany({
where: { userId },
skip,
take: limit,
orderBy: { submittedAt: 'desc' },
include: {
question: {
select: { id: true, title: true, scenario: true, difficulty: true, category: true },
},
},
}),
this.prisma.practiceSubmission.count({ where: { userId } }),
]);
return { items, total, page, limit };
}
async getSubmissionDetail(userId: number, submissionId: number) {
const submission = await this.prisma.practiceSubmission.findUnique({
where: { id: submissionId },
include: { question: true },
});
if (!submission) throw new NotFoundException('提交记录不存在');
if (submission.userId !== userId) throw new ForbiddenException('无权查看此提交');
return submission;
}
private buildScoringPrompt(question: any, answer: string, criteria: string): string {
return `请根据以下场景和评分标准,评估用户的回答。
## 练习场景
${question.scenario}
## 练习要求
${question.instructions}
## 用户的回答
${answer}
## 评分标准
${criteria}
## 输出格式(必须返回纯 JSON,不要包含 markdown 代码块)
{
"criteria": [
{ "name": "评分项名称", "score": 分数, "maxScore": 满分, "reason": "评分理由" }
],
"totalScore": 总分,
"feedback": "总体反馈和改进建议(50-200字)"
}`;
}
private parseScoreResult(aiResult: string): { criteria: any[]; totalScore: number; feedback: string } {
try {
const cleaned = aiResult.replace(/```json\s*/g, '').replace(/```\s*/g, '').trim();
const parsed = JSON.parse(cleaned);
return {
criteria: parsed.criteria || [],
totalScore: Math.min(100, Math.max(0, parsed.totalScore || 0)),
feedback: parsed.feedback || '评分完成',
};
} catch {
this.logger.warn(`AI 评分结果解析失败,使用默认评分: ${aiResult.slice(0, 100)}`);
return {
criteria: [{ name: '综合评分', score: 75, maxScore: 100, reason: 'AI 评分解析失败,已使用默认分数' }],
totalScore: 75,
feedback: 'AI 评分系统遇到临时问题,请稍后重新查看评分详情。',
};
}
}
}
View File
View File

Some files were not shown because too many files have changed in this diff Show More