Compare commits

..

24 Commits

Author SHA1 Message Date
TradeMate Dev 4ee0e546e3 fix: 清理 typecheck 报错并修正迁移列名
- 加入缺失的 @testing-library/dom 依赖(RTL v16 必需 peer),修复 screen 导入
- 修正测试文件错误的相对导入路径(header 改具名导入;prompts/courses 指向 ../page)
- 修正联盟/打赏迁移 SQL:Prisma 列名为 camelCase(仅 @@map 映射表名),原 snake_case 列名导致查询 500,已重建表
- npm run typecheck 现已通过(EXIT=0)
2026-07-11 20:00:14 +08:00
TradeMate Dev 6850ceff56 fix(seo): 详情页服务端渲染写入静态HTML并修复skills预渲染报错
- courses/skills/contents/practices 详情页改为服务端取数 + initialData 渲染,正文进入静态 HTML(解决百度弱 JS 收录盲点)
- contents 的 markdown 由 useEffect+setState 改为 useMemo 同步输出
- 四个 generateStaticParams 改用 API 真实 id;getX 校验 data.id 防后端 200+error 体被当有效数据导致 undefined.map 报错
- 更新进度文档至 v1.3.0
2026-07-11 17:46:31 +08:00
TradeMate Dev 09bf329544 feat(seo): 结构化数据/每页元数据/列表页metadata/hreflang修正/OG分享图
- 详情页注入 Article+BreadcrumbList JSON-LD,工具页 SoftwareApplication
- 课程/技能/文章/练习 动态 generateMetadata(canonical/OG)
- 新增 skills/contents/practices/prompts/models 列表页 metadata
- 修正 hreflang(删除不存在的/en),用 next/og 生成 OG 分享图
2026-07-11 17:23:56 +08:00
TradeMate Dev 8f13604e0e chore: 全站改为免费+联盟模式,原支付保留备用
- 课程详情页下架付费购买与扫码支付弹窗
- 首页/能力导览文案 购买→免费使用
- 付费面(会员/技能广场/用量包/课程)均已免费化,原 orders/payment 模块保留后端备用
2026-07-11 17:08:32 +08:00
TradeMate Dev 00a3904eba feat: 落地打赏(个人码捐赠)模式并将付费面改为免费+赞助(无ICP证合规)
- 新增 Donation 模型/迁移/后端 API(GET,POST /donations)
- 新增前端 /donate 页:收款码+感谢留言+感谢墙
- 会员页/技能广场/用量包 下架付费,改免费开放+赞助入口
- 沙箱用量耗尽引导至 /donate 赞助
- 导航与页脚新增 赞助/联盟返佣 入口,补充 i18n
2026-07-11 15:02:08 +08:00
TradeMate Dev 31cc1e02ce feat: 联盟返佣系统规范化并打通技能广场(无 ICP 证合规变现主路径)
- 新增 AffiliateProgram / AffiliateLink / AffiliateClick 规范化 Prisma 模型
  取代原手写裸表 affiliate_stats / affiliate_clicks
- 新增迁移 prisma/migrations/20260711000000_add_affiliate_models
- 重构 affiliate.service.ts 改用 Prisma ORM,消除 $queryRawUnsafe SQL 注入
- 重构 affiliate.controller.ts 接口:programs / links(?skillId,?toolId) / stats / click
- 前端 affiliate 页接入真实接口,移除硬编码 demo 数据
- 技能详情页新增「学此技能推荐使用的工具」联盟链接区块
- 新增 affiliate.service.spec.ts(4 用例通过)与幂等种子 seed-affiliate.ts
- 更新 docs/progress/current.md,明确无 ICP 经营许可证下以联盟返佣为合规变现主路径
- 含此前工作区未提交改动(工具 slug 路由、支付/订单、SEO 等)

Co-Authored-By: opencode <opencode@anthropic.com>
2026-07-11 14:47:41 +08:00
TradeMate Dev 05bf267d09 docs: update progress with P8.4 - @Res() bug fix, model verification, production deploy 2026-06-24 11:32:55 +08:00
TradeMate Dev 8a86334d05 fix: sandbox non-stream response not sent due to @Res() express native mode
- sandbox.controller.ts: non-stream chat path now calls res.json() instead
  of returning the value (which NestJS discards in @Res() library mode)
- ai-gateway.service.ts: 商汤科技 only registers sensenova model, no longer
  overwrites deepseek registration
- .gitignore: add .playwright-mcp/ and gitea-login.png
2026-06-23 23:21:28 +08:00
TradeMate Dev f4f17c10d8 build: 添加 postbuild sitemap 生成脚本 + robots.txt SEO
- package.json: 添加 postbuild 钩子自动生成 sitemap
- scripts/generate-sitemap.js: 站点地图生成器(78 条 URL)
- public/robots.txt: SEO 爬虫引导

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-06-23 21:24:20 +08:00
TradeMate Dev c10a33e9f8 docs: 新增返佣注册指南 + 进度更新至 P8.3
- affiliate-registration.md: 收录阿里云/百度云/腾讯云/Canva/WPS/讯飞
  等平台的注册入口、佣金比例、操作流程
- progress.md: 新增 P8.2(返佣工具库扩充)和 P8.3(模型合规优化)

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-06-23 21:24:17 +08:00
TradeMate Dev 5ddd7e3a82 feat: 数据层更新 — 新增国内工具种子脚本 + schema 优化
- schema.prisma: Prompt.content 改为 @db.Text 支持长文本
- seed-enrich.ts: deleteMany()→upsert by name 防止重跑时清除数据
- seed-expand.ts: 扩展 prompts 至 35 条 + 课程章节扩充
- seed-tools-affiliate.ts: 新增 8 个国内 AI 工具(豆包/通义千问/文心一言/
  WPS AI/Canva/腾讯元宝/讯飞星火/剪映),5 个带返佣链接

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-06-23 21:23:53 +08:00
TradeMate Dev 15ff424f11 feat: Admin 工具管理页新增返佣链接字段
- new/page.tsx: 表单添加 affiliateLink 输入字段 + 提示文案
- [id]/client.tsx: 从只读展示重写为可编辑表单,调用 PUT /admin/tools/:id 保存
                   添加 affiliateLink 字段,修复原页面不保存的 Bug

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-06-23 21:23:49 +08:00
TradeMate Dev 7c92729c3f feat: 前端沙盒模型列表仅显示国内已备案模型
- models.ts 移除 GPT/Claude/Gemini/Llama,仅保留 domestic 模型
- DEFAULT_MODEL 改为 deepseek-v4-flash(原为 general)
- ModelSelector 改为从 /sandbox/models 获取可用模型列表

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-06-23 21:23:25 +08:00
TradeMate Dev cd0ef01c7c feat: 沙盒模型合规优化 — 移除境外模型引用,新增 GET /sandbox/models 接口
- MODEL_CATALOG 仅保留国内已备案模型(DeepSeek/SenseNova)
- chat()/chatStream() modelMap 仅路由到国内 provider
- provider 命名明确化:"OpenAI 兼容接口" → "硅基流动 / DeepSeek"
- 新增 getSandboxModels() 动态返回可用模型列表
- sandbox 控制器拆分 auth 级别,GET /sandbox/models 无需认证
- 新增 getAvailableModels() 供前端调用

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-06-23 21:23:21 +08:00
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
394 changed files with 20161 additions and 3707 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
+12
View File
@@ -13,6 +13,8 @@ out/
# Environment files # Environment files
.env .env
.env.local .env.local
.playwright-mcp/
gitea-login.png
.env.*.local .env.*.local
backend/.env backend/.env
frontend/.env.local frontend/.env.local
@@ -53,6 +55,12 @@ backend/cert/key/*.pem
backend/cert/key/*.p12 backend/cert/key/*.p12
backend/cert/key/*.txt backend/cert/key/*.txt
# Build cache
*.tsbuildinfo
# AI agent workspace
.omo/
# Temp files # Temp files
*.tmp *.tmp
*.temp *.temp
@@ -60,3 +68,7 @@ backend/cert/key/*.txt
# Playwright E2E test output # Playwright E2E test output
test-results/ test-results/
playwright-report/ playwright-report/
# Generated distribution archive
Distribution/
distribution/
Regular → Executable
+35 -107
View File
@@ -1,121 +1,49 @@
# AGENTS.md — 项目知识库(AI 专用) # AGENTS.md — 项目知识库(AI 专用)
> 每次任务前先读此文件。维护项目关键上下文,避免重复探索 宇之然 AI 工具指南与技能练习平台。前端 Next.js 14,后端 NestJS,另有独立的 uni-app 移动端
## 项目概览 ## 仓库结构(monorepo,各自独立 `npm install`
宇之然 AI 学习与实践平台。前端 Next.js + shadcn/ui,后端 NestJS + PostgreSQL。 | 目录 | 技术栈 | 端口 | 说明 |
|------|--------|------|------|
| 项目 | 值 | | `frontend/` | Next.js 14 App Router | 3000 | SSG 静态导出(`output: 'export'`),构建产物到 `out/` |
|------|-----| | `backend/` | NestJS + Prisma + MySQL | 4000 | API 前缀 `/api/v1`JWT + Passport 认证 |
| 品牌 | 宇之然(北京宇之然科技中心) | | `mobile/` | uni-app (Vue3) | — | 微信小程序/H5,**与 Web 端无代码共享**,改动它需单独评估 |
| 域名 | yuzhiran.com |
| 前端 | `frontend/`, port 3000, Next.js App Router |
| 后端 | `backend/`, port 4000, NestJS |
| CSS | Tailwind CSS + shadcn/ui 暗黑模式 |
## 技术栈约定
### 前端
- **框架**: Next.js App Router (`src/app/`)
- **UI**: shadcn/ui + Tailwind CSS (CSS 变量主题)
- **状态管理**: React Server Components 优先, 客户端用 `useState`/`useEffect`
- **路由**: 文件系统路由, 布局用 `layout.tsx`, 加载用 `loading.tsx`, 错误用 `error.tsx`
- **暗黑模式**: `next-themes` + Tailwind 暗类策略 (`darkMode: 'class'`)
### 后端
- **框架**: NestJS 模块化架构
- **ORM**: TypeORM + PostgreSQLschema 在 `database/migrations/`
- **认证**: JWT + Passport
- **API 前缀**: `/api/v1`
- **CORS**: `origin: true`, `maxAge: 0`(避免浏览器预检缓存)
### UI/UX 统一规则
- **容器**: 内容页 `max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-12`;法律/文本页用 `max-w-3xl`
- **标题**: `text-3xl font-bold text-foreground`
- **副标题**: `<p className="mt-2 text-muted-foreground">...</p>`
- **颜色**: 全站禁用 `text-gray-*` / `bg-gray-*` / `border-gray-*` 硬编码 —— 使用 CSS 变量:
- 前景色 → `text-foreground` / `text-muted-foreground`
- 背景 → `bg-card` / `bg-muted/50` / `bg-accent`
- 边框 → `border-border`
- hover → `hover:text-foreground` / `hover:bg-accent`
- **国际化**: 所有用户可见文本使用 `useT()` hook,翻译 key 位于 `src/i18n/locales/`。新增页面先加翻译 key 再写 UI。中文默认,英文同步维护。
## 关键架构决策
| 决策 | 方案 | 原因 |
|------|------|------|
| 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` | 基于对话知识度分析 |
## 后端关键 API
| 端点 | 说明 |
|------|------|
| `POST /api/v1/auth/register` | 注册 |
| `POST /api/v1/auth/login` | 登录 |
| `GET /api/v1/search?q=xxx` | 搜索(返回 `{ results: [...] }`**不是** `items` |
| `GET /api/v1/users/:id` | 用户信息 |
| `POST /api/v1/chat/completions` | AI 流式对话 |
| `GET /api/v1/enterprise/...` | 企业版管理 |
| `GET /api/v1/notifications` | 通知列表(需 JWT |
| `GET /api/v1/notifications/unread` | 未读数(需 JWT |
| `PATCH /api/v1/notifications/:id/read` | 标记已读(需 JWT |
| `PATCH /api/v1/notifications/read-all` | 全部已读(需 JWT |
| `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/categories` | 技能分类列表 |
| `GET /api/v1/skills/difficulties` | 难度等级列表 |
## 关键上下文(Critical Context
- `search.service.ts` 返回 `results` 而非 `items` —— 写测试时注意
- `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/`
## 项目管理流程
每次任务遵循以下流程:
1. **任务开始前** — 读 AGENTS.md + docs/progress.md,了解当前进度和上下文
2. **规划阶段** — 分析需求,拆解为 TODO 列表,按优先级排序
3. **执行阶段** — 按 TODO 依次实施,每次完成后验证(lint/test/build
4. **任务完成后** — 更新 docs/progress.md(更新阶段状态、补充完成项)
5. **归档** — 旧版 progress.md 移到 `docs/archive/progress-YYYY-MM-DD.md`
6. **提交** — 只有在用户明确要求时才创建 git commit
## 常用命令 ## 常用命令
```bash ```bash
# 前端开发 # 前端
cd frontend && npm run dev cd frontend && npm run dev # predev 会自动 rm -rf out
cd frontend && npm run build # next build + 自动生成 sitemap(输出到 out/
cd frontend && npm run typecheck # tsc --noEmit
cd frontend && npm test # vitestsrc/**/*.{test,spec}.{ts,tsx}
cd frontend && npm run test:e2e # playwrightBASE_URL 默认 localhost:3000
# 后端开发 # 后端
cd backend && npm run start:dev cd backend && npm run start:dev # nest start --watch
cd backend && npm test # jest*.spec.ts);单文件:npx jest <path>.spec.ts
# 前端构建 cd backend && npx jest --config jest-e2e.json # e2etest/app.e2e-spec.ts,无 npm script
cd frontend && npm run build cd backend && npm run prisma:generate | prisma:migrate | prisma:seed # prisma 工具
# 后端测试
cd backend && npm test
npm run test:e2e
``` ```
--- ## 前端约定(agent 容易踩坑)
> 此文件仅在架构/约定变更时更新。进度追踪见 `docs/progress.md` - **国际化**:用 `useT()`(来自 `@/i18n`),翻译 key 在 `src/i18n/locales/`,中文默认、英文同步。不要硬编码用户可见文本
- **颜色**:禁用 `text-gray-*` / `bg-gray-*` / `border-gray-*` 硬编码,统一用 CSS 变量(`text-foreground`/`text-muted-foreground``bg-card`/`bg-muted`/`bg-accent``border-border`、hover 用 `hover:bg-accent` 等)。
- **容器**:内容页 `max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-12`;长文本页 `max-w-3xl`
--- ## 已验证的坑(改代码前注意)
> 此文件仅在架构/约定变更时更新。进度追踪见 `docs/progress.md` - **搜索 API 返回 `{ results, total }`,不是 `items`**`backend/src/modules/search/search.service.ts`)。写前端/测试时勿用错字段名
- **前端是 SSG**`output: 'export'`)。`next build` 时所有动态路由依赖 `generateStaticParams()`,构建期间需要后端运行在 `localhost:4000`,否则拿不到参数。图片用 `unoptimized: true`
- **ESLint 循环引用**已在 `next.config.js``eslint: { ignoreDuringBuilds: true }` 绕过 —— 构建不报 lint 错,需单独 `npm run lint` / `npm run typecheck`
- **不要同时创建 `public/favicon.ico``src/app/favicon.ico`**Next 导出会 500。
- 后端 jest/ts-jest 的 `@/` 别名指向 `src/``moduleNameMapper`)。
## 技能广场(marketplace)数据模型
- `Skill``price` 字段:`null`=免费,`>0`=付费;`UserSkill` 记录购买;`Order.metadata``skillId`
- 购买流程:`POST /orders/create``planType:'SKILL'`, `skillId`)→ mock 支付自动建 `UserSkill` → 广场显示"已拥有"。
> 进度追踪见 `docs/progress.md`;架构细节见 `docs/技术架构设计.md`、README.md。本文件仅在架构/约定变更时更新。
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/ ai-learning-platform/
├── README.md # 项目说明 ├── README.md # 项目说明
├── AGENTS.md # AI 项目知识库(开发助手专用)
├── docs/ # 项目文档 ├── docs/ # 项目文档
│ ├── progress.md # 进度追踪
│ ├── archive/ # 历史进度存档
│ ├── 产品规划书.md # 完整产品规划(含商业模式、可行性分析) │ ├── 产品规划书.md # 完整产品规划(含商业模式、可行性分析)
│ ├── 技术架构设计.md # 技术选型与系统架构 │ ├── 技术架构设计.md # 技术选型与系统架构
│ └── 合规与运营方案.md # ICP合规、数据合规、运营策略 │ └── 合规与运营方案.md # ICP合规、数据合规、运营策略
├── frontend/ # 官网前端(Next.js + Tailwind ├── frontend/ # 前端(Next.js 14 App Router + shadcn/ui
├── mobile/ # 移动端 App/小程序
├── backend/ # 后端 APINestJS + Prisma + MySQL ├── backend/ # 后端 APINestJS + Prisma + MySQL
│ ├── src/modules/payment/ # 微信支付模块(已完善) │ ├── src/modules/ # 功能模块
│ ├── src/modules/ai/ # AI模型接入(支持OpenAI兼容接口 │ ├── payment/ # 支付模块(统一支付网关对接
└── prisma/ # 数据模型定义 │ ├── ai/ # AI 模型接入(OpenAI/Doubao/DeepSeek
│ │ ├── sandbox/ # AI 实操沙箱(多场景练习)
│ │ ├── skills/ # 技能系统(付费 Skill 商店)
│ │ ├── tools/ # AI 工具指南
│ │ ├── practices/ # 练习系统
│ │ └── marketplace/ # 技能广场 API
│ ├── prisma/ # 数据模型定义(含 seed 脚本)
│ └── cert/ # 支付证书
└── scripts/ # 工具脚本 └── 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 工具指南与技能练习平台**,帮助用户
- AI 基础知识与通识教育 - 发现优质 **AI 工具**(含返佣推荐)
- 提示词工程教学与模板库 - 系统学习 **Prompt 工程**(体系化课程)
- 智能体(Agent)使用教程 - **AI 实操沙箱** 中练习提示词技能
- 大模型介绍与选型指南 - 购买一次性付费 **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 服务", "description": "宇之然 AI - 后端 API 服务",
"main": "dist/main.js", "main": "dist/main.js",
"scripts": { "scripts": {
"build": "nest build", "build": "nest build --tsc",
"start": "nest start", "start": "nest start",
"dev": "nest start --watch", "dev": "nest start --watch",
"start:prod": "node dist/main", "start:prod": "node dist/main",
@@ -37,12 +37,16 @@
"@nestjs/platform-express": "^11.1.19", "@nestjs/platform-express": "^11.1.19",
"@nestjs/serve-static": "^5.0.5", "@nestjs/serve-static": "^5.0.5",
"@nestjs/swagger": "^11.4.2", "@nestjs/swagger": "^11.4.2",
"@nestjs/throttler": "^6.5.0",
"@prisma/client": "^5.22.0", "@prisma/client": "^5.22.0",
"alipay-sdk": "^4.14.0",
"bcryptjs": "^3.0.3", "bcryptjs": "^3.0.3",
"class-transformer": "^0.5.1", "class-transformer": "^0.5.1",
"class-validator": "^0.15.1", "class-validator": "^0.15.1",
"compression": "^1.8.1", "compression": "^1.8.1",
"cookie-parser": "^1.4.7",
"helmet": "^8.1.0", "helmet": "^8.1.0",
"ioredis": "^5.11.1",
"passport": "^0.7.0", "passport": "^0.7.0",
"passport-jwt": "^4.0.1", "passport-jwt": "^4.0.1",
"prisma": "^5.22.0", "prisma": "^5.22.0",
@@ -56,12 +60,16 @@
"@nestjs/testing": "^11.1.19", "@nestjs/testing": "^11.1.19",
"@types/bcryptjs": "^2.4.6", "@types/bcryptjs": "^2.4.6",
"@types/compression": "^1.8.1", "@types/compression": "^1.8.1",
"@types/cookie-parser": "^1.4.10",
"@types/express": "^5.0.6", "@types/express": "^5.0.6",
"@types/jest": "^30.0.0", "@types/jest": "^30.0.0",
"@types/multer": "^2.1.0", "@types/multer": "^2.1.0",
"@types/node": "^20.19.40", "@types/node": "^20.19.40",
"@types/passport-jwt": "^4.0.1", "@types/passport-jwt": "^4.0.1",
"@types/supertest": "^7.2.0", "@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", "jest": "^30.4.0",
"supertest": "^7.2.2", "supertest": "^7.2.2",
"ts-jest": "^29.4.9", "ts-jest": "^29.4.9",
@@ -0,0 +1,63 @@
-- 联盟返佣系统:规范化数据模型
-- 替换原先手写的 affiliate_stats / affiliate_clicks 裸表,改用 ORM 管理
-- 注意:Prisma 字段名即为列名(camelCase),仅通过 @@map 映射表名为 snake_case
CREATE TABLE IF NOT EXISTS `affiliate_programs` (
`id` INT NOT NULL AUTO_INCREMENT,
`name` VARCHAR(191) NOT NULL,
`slug` VARCHAR(191) NOT NULL,
`provider` VARCHAR(191),
`description` TEXT,
`commissionRate` VARCHAR(191),
`cookieDays` INT,
`homepageUrl` VARCHAR(191),
`logo` VARCHAR(191),
`active` TINYINT(1) NOT NULL DEFAULT 1,
`sortOrder` INT NOT NULL DEFAULT 0,
`createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
`updatedAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
PRIMARY KEY (`id`),
UNIQUE INDEX `affiliate_programs_slug_key` (`slug`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS `affiliate_links` (
`id` INT NOT NULL AUTO_INCREMENT,
`programId` INT NOT NULL,
`title` VARCHAR(191) NOT NULL,
`description` TEXT,
`url` TEXT NOT NULL,
`thumbnail` VARCHAR(191),
`toolId` INT,
`skillId` VARCHAR(191),
`tags` TEXT,
`estimatedCommission` DOUBLE NOT NULL DEFAULT 0,
`sortOrder` INT NOT NULL DEFAULT 0,
`isFeatured` TINYINT(1) NOT NULL DEFAULT 0,
`active` TINYINT(1) NOT NULL DEFAULT 1,
`createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
`updatedAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
PRIMARY KEY (`id`),
INDEX `affiliate_links_programId_idx` (`programId`),
INDEX `affiliate_links_toolId_idx` (`toolId`),
INDEX `affiliate_links_skillId_idx` (`skillId`),
CONSTRAINT `affiliate_links_programId_fkey` FOREIGN KEY (`programId`) REFERENCES `affiliate_programs` (`id`) ON DELETE CASCADE,
CONSTRAINT `affiliate_links_toolId_fkey` FOREIGN KEY (`toolId`) REFERENCES `tools` (`id`) ON DELETE SET NULL,
CONSTRAINT `affiliate_links_skillId_fkey` FOREIGN KEY (`skillId`) REFERENCES `skills` (`id`) ON DELETE SET NULL
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS `affiliate_clicks` (
`id` INT NOT NULL AUTO_INCREMENT,
`linkId` INT NOT NULL,
`userId` INT,
`ip` VARCHAR(191),
`userAgent` TEXT,
`ref` VARCHAR(191),
`createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
PRIMARY KEY (`id`),
INDEX `affiliate_clicks_linkId_idx` (`linkId`),
CONSTRAINT `affiliate_clicks_linkId_fkey` FOREIGN KEY (`linkId`) REFERENCES `affiliate_links` (`id`) ON DELETE CASCADE
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
-- 清理旧的手写裸表(被本迁移的规范化模型取代)
DROP TABLE IF EXISTS `affiliate_stats`;
DROP TABLE IF EXISTS `affiliate_clicks_old`;
@@ -0,0 +1,14 @@
-- 支持模式:用户自愿打赏(个人收款码捐赠)留言记录
-- 平台不代收任何费用,此处仅用于展示「感谢墙」,不记录支付流水
-- 注意:Prisma 字段名即为列名(camelCase),仅通过 @@map 映射表名为 snake_case
CREATE TABLE IF NOT EXISTS `donations` (
`id` INT NOT NULL AUTO_INCREMENT,
`name` VARCHAR(191),
`message` VARCHAR(280),
`channel` VARCHAR(191) NOT NULL DEFAULT 'ALIPAY',
`amount` DOUBLE,
`createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
PRIMARY KEY (`id`),
INDEX `donations_createdAt_idx` (`createdAt`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
Regular → Executable
+223 -81
View File
@@ -7,31 +7,6 @@ datasource db {
url = env("DATABASE_URL") url = env("DATABASE_URL")
} }
enum UserStatus {
ACTIVE
INACTIVE
BANNED
}
enum ContentStatus {
DRAFT
PUBLISHED
ARCHIVED
}
enum OrderStatus {
PENDING
PAID
CANCELLED
REFUNDED
}
enum MemberPlan {
FREE
MONTHLY
YEARLY
}
model User { model User {
id Int @id @default(autoincrement()) id Int @id @default(autoincrement())
phone String? @unique phone String? @unique
@@ -43,7 +18,7 @@ model User {
status UserStatus @default(ACTIVE) status UserStatus @default(ACTIVE)
memberPlan MemberPlan @default(FREE) memberPlan MemberPlan @default(FREE)
memberExpire DateTime? memberExpire DateTime?
sandboxDaily Int @default(10) sandboxDaily Int @default(5)
followerCount Int @default(0) followerCount Int @default(0)
followingCount Int @default(0) followingCount Int @default(0)
postCount Int @default(0) postCount Int @default(0)
@@ -51,22 +26,25 @@ model User {
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
deletedAt DateTime? deletedAt DateTime?
username String? @unique
sandboxExtra Int @default(0)
circleMemberships CircleMember[]
createdCircles Circle[] @relation("CircleCreator")
comments Comment[]
following Follow[] @relation("Follower")
followers Follow[] @relation("Following")
learnRecords LearnRecord[] learnRecords LearnRecord[]
notifications Notification[]
orders Order[]
organizationMemberships OrganizationMember[]
postLikes PostLike[]
posts Post[]
practiceSubmissions PracticeSubmission[]
promptFavorites PromptFavorite[] promptFavorites PromptFavorite[]
prompts Prompt[] prompts Prompt[]
sandboxSessions SandboxSession[] sandboxSessions SandboxSession[]
orders Order[]
subscriptions Subscription[] subscriptions Subscription[]
posts Post[] userSkills UserSkill[]
comments Comment[]
postLikes PostLike[]
followers Follow[] @relation("Following")
following Follow[] @relation("Follower")
createdCircles Circle[] @relation("CircleCreator")
circleMemberships CircleMember[]
organizationMemberships OrganizationMember[]
notifications Notification[]
@@map("users") @@map("users")
} }
@@ -80,15 +58,14 @@ model Category {
parentId Int? parentId Int?
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
parent Category? @relation("CategoryTree", fields: [parentId], references: [id]) parent Category? @relation("CategoryTree", fields: [parentId], references: [id])
children Category[] @relation("CategoryTree") children Category[] @relation("CategoryTree")
contents Content[]
courses Course[] courses Course[]
prompts Prompt[] prompts Prompt[]
contents Content[]
tools Tool[] tools Tool[]
@@index([parentId], map: "categories_parentId_fkey")
@@map("categories") @@map("categories")
} }
@@ -105,12 +82,12 @@ model Course {
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
deletedAt DateTime? deletedAt DateTime?
category Category? @relation(fields: [categoryId], references: [id])
chapters Chapter[] chapters Chapter[]
progress LearnRecord[]
assignments CourseAssignment[] assignments CourseAssignment[]
category Category? @relation(fields: [categoryId], references: [id])
progress LearnRecord[]
@@index([categoryId], map: "courses_categoryId_fkey")
@@map("courses") @@map("courses")
} }
@@ -120,10 +97,10 @@ model Chapter {
title String title String
sortOrder Int @default(0) sortOrder Int @default(0)
createdAt DateTime @default(now()) createdAt DateTime @default(now())
course Course @relation(fields: [courseId], references: [id], onDelete: Cascade) course Course @relation(fields: [courseId], references: [id], onDelete: Cascade)
lessons Lesson[] lessons Lesson[]
@@index([courseId], map: "chapters_courseId_fkey")
@@map("chapters") @@map("chapters")
} }
@@ -131,17 +108,17 @@ model Lesson {
id Int @id @default(autoincrement()) id Int @id @default(autoincrement())
chapterId Int chapterId Int
title String title String
content String? content String? @db.Text
videoUrl String? videoUrl String?
duration Int? duration Int?
sortOrder Int @default(0) sortOrder Int @default(0)
status ContentStatus @default(DRAFT) status ContentStatus @default(DRAFT)
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
chapter Chapter @relation(fields: [chapterId], references: [id], onDelete: Cascade)
progress LearnRecord[] progress LearnRecord[]
chapter Chapter @relation(fields: [chapterId], references: [id], onDelete: Cascade)
@@index([chapterId], map: "lessons_chapterId_fkey")
@@map("lessons") @@map("lessons")
} }
@@ -154,19 +131,20 @@ model LearnRecord {
progress Float @default(0) progress Float @default(0)
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
course Course @relation(fields: [courseId], references: [id], onDelete: Cascade) course Course @relation(fields: [courseId], references: [id], onDelete: Cascade)
lesson Lesson @relation(fields: [lessonId], references: [id], onDelete: Cascade) lesson Lesson @relation(fields: [lessonId], references: [id], onDelete: Cascade)
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@unique([userId, lessonId]) @@unique([userId, lessonId])
@@index([courseId], map: "learn_records_courseId_fkey")
@@index([lessonId], map: "learn_records_lessonId_fkey")
@@map("learn_records") @@map("learn_records")
} }
model Prompt { model Prompt {
id Int @id @default(autoincrement()) id Int @id @default(autoincrement())
title String title String
content String content String @db.Text
description String? description String?
categoryId Int? categoryId Int?
tags String? tags String?
@@ -179,11 +157,12 @@ model Prompt {
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
deletedAt DateTime? deletedAt DateTime?
category Category? @relation(fields: [categoryId], references: [id])
author User? @relation(fields: [authorId], references: [id])
favorites PromptFavorite[] favorites PromptFavorite[]
author User? @relation(fields: [authorId], references: [id])
category Category? @relation(fields: [categoryId], references: [id])
@@index([authorId], map: "prompts_authorId_fkey")
@@index([categoryId], map: "prompts_categoryId_fkey")
@@map("prompts") @@map("prompts")
} }
@@ -192,18 +171,22 @@ model PromptFavorite {
userId Int userId Int
promptId Int promptId Int
createdAt DateTime @default(now()) createdAt DateTime @default(now())
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
prompt Prompt @relation(fields: [promptId], references: [id], onDelete: Cascade) prompt Prompt @relation(fields: [promptId], references: [id], onDelete: Cascade)
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@unique([userId, promptId]) @@unique([userId, promptId])
@@index([promptId], map: "prompt_favorites_promptId_fkey")
@@map("prompt_favorites") @@map("prompt_favorites")
} }
model Tool { model Tool {
id Int @id @default(autoincrement()) id Int @id @default(autoincrement())
slug String? @unique(map: "slug") @db.VarChar(200)
name String name String
metaTitle String? @db.VarChar(200)
metaDesc String? @db.VarChar(300)
description String? description String?
content String? @db.LongText
url String url String
icon String? icon String?
categoryId Int? categoryId Int?
@@ -214,9 +197,10 @@ model Tool {
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
deletedAt DateTime? deletedAt DateTime?
affiliateLink String?
category Category? @relation(fields: [categoryId], references: [id]) category Category? @relation(fields: [categoryId], references: [id])
@@index([categoryId], map: "tools_categoryId_fkey")
@@map("tools") @@map("tools")
} }
@@ -224,7 +208,7 @@ model Content {
id Int @id @default(autoincrement()) id Int @id @default(autoincrement())
title String title String
summary String? summary String?
content String? content String? @db.Text
cover String? cover String?
categoryId Int? categoryId Int?
tags String? tags String?
@@ -237,9 +221,9 @@ model Content {
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
deletedAt DateTime? deletedAt DateTime?
category Category? @relation(fields: [categoryId], references: [id]) category Category? @relation(fields: [categoryId], references: [id])
@@index([categoryId], map: "contents_categoryId_fkey")
@@map("contents") @@map("contents")
} }
@@ -279,6 +263,8 @@ model Skill {
status String @default("ACTIVE") status String @default("ACTIVE")
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
price Float? @default(0)
purchasedBy UserSkill[]
@@map("skills") @@map("skills")
} }
@@ -294,7 +280,6 @@ model SandboxSession {
tokens Int @default(0) tokens Int @default(0)
duration Int @default(0) duration Int @default(0)
createdAt DateTime @default(now()) createdAt DateTime @default(now())
user User @relation(fields: [userId], references: [id], onDelete: Cascade) user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@unique([userId, conversationId]) @@unique([userId, conversationId])
@@ -313,14 +298,33 @@ model Order {
paidAt DateTime? paidAt DateTime?
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
transactionId String?
gatewayOrderId String?
metadata String?
user User @relation(fields: [userId], references: [id]) user User @relation(fields: [userId], references: [id])
userSkills UserSkill[]
@@index([userId, status]) @@index([userId, status])
@@index([status, createdAt]) @@index([status, createdAt])
@@map("orders") @@map("orders")
} }
model UserSkill {
id Int @id @default(autoincrement())
userId Int
skillId String
orderId Int?
purchasedAt DateTime @default(now())
order Order? @relation(fields: [orderId], references: [id])
skill Skill @relation(fields: [skillId], references: [id])
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@unique([userId, skillId])
@@index([orderId], map: "user_skills_orderId_fkey")
@@index([skillId], map: "user_skills_skillId_fkey")
@@map("user_skills")
}
model Subscription { model Subscription {
id Int @id @default(autoincrement()) id Int @id @default(autoincrement())
userId Int userId Int
@@ -330,7 +334,6 @@ model Subscription {
status String @default("ACTIVE") status String @default("ACTIVE")
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
user User @relation(fields: [userId], references: [id]) user User @relation(fields: [userId], references: [id])
@@index([userId, status]) @@index([userId, status])
@@ -341,7 +344,7 @@ model AdminRole {
id String @id @default(cuid()) id String @id @default(cuid())
name String @unique name String @unique
description String? description String?
permissions Json @default("[]") permissions Json
status String @default("ACTIVE") status String @default("ACTIVE")
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
@@ -356,12 +359,13 @@ model AdminUser {
passwordHash String passwordHash String
nickname String? nickname String?
roleId String? roleId String?
role AdminRole? @relation(fields: [roleId], references: [id])
status String @default("ACTIVE") status String @default("ACTIVE")
lastLoginAt DateTime? lastLoginAt DateTime?
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
role AdminRole? @relation(fields: [roleId], references: [id])
@@index([roleId], map: "admin_users_roleId_fkey")
@@map("admin_users") @@map("admin_users")
} }
@@ -381,7 +385,7 @@ model Post {
id Int @id @default(autoincrement()) id Int @id @default(autoincrement())
userId Int userId Int
title String title String
content String content String @db.Text
tags String? tags String?
circleId Int? circleId Int?
status String @default("PUBLISHED") status String @default("PUBLISHED")
@@ -390,11 +394,10 @@ model Post {
commentCount Int @default(0) commentCount Int @default(0)
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
comments Comment[] comments Comment[]
likes PostLike[] likes PostLike[]
circle Circle? @relation(fields: [circleId], references: [id]) circle Circle? @relation(fields: [circleId], references: [id])
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@index([userId]) @@index([userId])
@@index([status, createdAt]) @@index([status, createdAt])
@@ -412,14 +415,15 @@ model Comment {
reviewNote String? reviewNote String?
reviewedAt DateTime? reviewedAt DateTime?
createdAt DateTime @default(now()) createdAt DateTime @default(now())
post Post @relation(fields: [postId], references: [id], onDelete: Cascade)
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
parent Comment? @relation("CommentReply", fields: [parentId], references: [id]) parent Comment? @relation("CommentReply", fields: [parentId], references: [id])
replies Comment[] @relation("CommentReply") replies Comment[] @relation("CommentReply")
post Post @relation(fields: [postId], references: [id], onDelete: Cascade)
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@index([postId]) @@index([postId])
@@index([status]) @@index([status])
@@index([parentId], map: "comments_parentId_fkey")
@@index([userId], map: "comments_userId_fkey")
@@map("comments") @@map("comments")
} }
@@ -428,11 +432,11 @@ model PostLike {
postId Int postId Int
userId Int userId Int
createdAt DateTime @default(now()) createdAt DateTime @default(now())
post Post @relation(fields: [postId], references: [id], onDelete: Cascade) post Post @relation(fields: [postId], references: [id], onDelete: Cascade)
user User @relation(fields: [userId], references: [id], onDelete: Cascade) user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@unique([postId, userId]) @@unique([postId, userId])
@@index([userId], map: "post_likes_userId_fkey")
@@map("post_likes") @@map("post_likes")
} }
@@ -445,11 +449,11 @@ model Circle {
isPublic Boolean @default(true) isPublic Boolean @default(true)
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
creator User @relation("CircleCreator", fields: [creatorId], references: [id])
members CircleMember[] members CircleMember[]
creator User @relation("CircleCreator", fields: [creatorId], references: [id])
posts Post[] posts Post[]
@@index([creatorId], map: "circles_creatorId_fkey")
@@map("circles") @@map("circles")
} }
@@ -459,11 +463,11 @@ model CircleMember {
userId Int userId Int
role String @default("member") role String @default("member")
joinedAt DateTime @default(now()) joinedAt DateTime @default(now())
circle Circle @relation(fields: [circleId], references: [id], onDelete: Cascade) circle Circle @relation(fields: [circleId], references: [id], onDelete: Cascade)
user User @relation(fields: [userId], references: [id], onDelete: Cascade) user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@unique([circleId, userId]) @@unique([circleId, userId])
@@index([userId], map: "circle_members_userId_fkey")
@@map("circle_members") @@map("circle_members")
} }
@@ -472,11 +476,11 @@ model Follow {
followerId Int followerId Int
followingId Int followingId Int
createdAt DateTime @default(now()) createdAt DateTime @default(now())
follower User @relation("Follower", fields: [followerId], references: [id], onDelete: Cascade) follower User @relation("Follower", fields: [followerId], references: [id], onDelete: Cascade)
following User @relation("Following", fields: [followingId], references: [id], onDelete: Cascade) following User @relation("Following", fields: [followingId], references: [id], onDelete: Cascade)
@@unique([followerId, followingId]) @@unique([followerId, followingId])
@@index([followingId], map: "follows_followingId_fkey")
@@map("follows") @@map("follows")
} }
@@ -491,9 +495,8 @@ model Organization {
status String @default("ACTIVE") status String @default("ACTIVE")
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
members OrganizationMember[]
assignments CourseAssignment[] assignments CourseAssignment[]
members OrganizationMember[]
@@map("organizations") @@map("organizations")
} }
@@ -505,11 +508,11 @@ model OrganizationMember {
role String @default("MEMBER") role String @default("MEMBER")
status String @default("ACTIVE") status String @default("ACTIVE")
joinedAt DateTime @default(now()) joinedAt DateTime @default(now())
organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade) organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
user User @relation(fields: [userId], references: [id], onDelete: Cascade) user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@unique([organizationId, userId]) @@unique([organizationId, userId])
@@index([userId], map: "organization_members_userId_fkey")
@@map("organization_members") @@map("organization_members")
} }
@@ -520,30 +523,74 @@ model CourseAssignment {
assignedBy Int assignedBy Int
deadline DateTime? deadline DateTime?
createdAt DateTime @default(now()) createdAt DateTime @default(now())
organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
course Course @relation(fields: [courseId], references: [id], onDelete: Cascade) course Course @relation(fields: [courseId], references: [id], onDelete: Cascade)
organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
@@index([courseId], map: "course_assignments_courseId_fkey")
@@index([organizationId], map: "course_assignments_organizationId_fkey")
@@map("course_assignments") @@map("course_assignments")
} }
model Notification { model Notification {
id Int @id @default(autoincrement()) id Int @id @default(autoincrement())
userId Int userId Int
type String // 'like', 'comment', 'follow', 'system' type String
title String title String
content String? content String?
link String? link String?
relatedId Int? relatedId Int?
isRead Boolean @default(false) isRead Boolean @default(false)
createdAt DateTime @default(now()) createdAt DateTime @default(now())
user User @relation(fields: [userId], references: [id], onDelete: Cascade) user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@index([userId, isRead]) @@index([userId, isRead])
@@map("notifications") @@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 { model Banner {
id Int @id @default(autoincrement()) id Int @id @default(autoincrement())
title String title String
@@ -588,3 +635,98 @@ model SystemConfig {
@@map("system_configs") @@map("system_configs")
} }
enum UserStatus {
ACTIVE
INACTIVE
BANNED
}
enum ContentStatus {
DRAFT
PUBLISHED
ARCHIVED
}
enum OrderStatus {
PENDING
PAID
CANCELLED
REFUNDED
}
enum MemberPlan {
FREE
MONTHLY
YEARLY
}
model AffiliateProgram {
id Int @id @default(autoincrement())
name String
slug String @unique
provider String?
description String?
commissionRate String?
cookieDays Int?
homepageUrl String?
logo String?
active Boolean @default(true)
sortOrder Int @default(0)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
links AffiliateLink[]
@@map("affiliate_programs")
}
model AffiliateLink {
id Int @id @default(autoincrement())
programId Int
title String
description String?
url String
thumbnail String?
toolId Int?
skillId String?
tags String?
estimatedCommission Float @default(0)
sortOrder Int @default(0)
isFeatured Boolean @default(false)
active Boolean @default(true)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
clicks AffiliateClick[]
program AffiliateProgram @relation(fields: [programId], references: [id], onDelete: Cascade)
@@index([programId])
@@index([toolId])
@@index([skillId])
@@map("affiliate_links")
}
model AffiliateClick {
id Int @id @default(autoincrement())
linkId Int
userId Int?
ip String?
userAgent String?
ref String?
createdAt DateTime @default(now())
link AffiliateLink @relation(fields: [linkId], references: [id], onDelete: Cascade)
@@index([linkId])
@@map("affiliate_clicks")
}
model Donation {
id Int @id @default(autoincrement())
name String?
message String? @db.VarChar(280)
channel String @default("ALIPAY")
amount Float?
createdAt DateTime @default(now())
@@index([createdAt])
@@map("donations")
}
+100
View File
@@ -0,0 +1,100 @@
import { PrismaClient } from '@prisma/client';
const prisma = new PrismaClient();
/**
* 联盟返佣种子:创建「阿里云云大使」推广计划,并把技能/工具关联到推广链接。
* 幂等(upsert),可重复执行。部署时运行:npx ts-node prisma/seed-affiliate.ts
*/
async function main() {
const program = await prisma.affiliateProgram.upsert({
where: { slug: 'aliyun-promoter' },
update: { active: true },
create: {
name: '阿里云云大使',
slug: 'aliyun-promoter',
provider: '阿里云',
description: '阿里云推广大使联盟,用户通过推荐链接购买云产品,平台获得返佣。',
commissionRate: '最高 20%',
cookieDays: 30,
homepageUrl: 'https://promotion.aliyun.com/',
sortOrder: 0,
active: true,
},
});
const links = [
{
title: '阿里云服务器 ECS(新用户特惠)',
description: '推荐购买阿里云 ECS 云服务器,新用户首购低至 1 折。',
url: 'https://dashi.aliyun.com/activity/oss?userCode=yzrcloud',
skillSlug: 'title-craft',
toolName: '通义千问',
tags: '云计算,服务器,阿里云',
estimatedCommission: 15,
isFeatured: true,
},
{
title: '阿里云 AI 大模型 API(百炼平台)',
description: '通义千问/百炼大模型 API,按量计费,适合开发者接入。',
url: 'https://bailian.aliyun.com/?userCode=yzrcloud',
skillSlug: 'payment-chaser',
toolName: '通义千问',
tags: '大模型,API,AI',
estimatedCommission: 12,
isFeatured: true,
},
{
title: '阿里云对象存储 OSS',
description: '推荐企业/开发者使用 OSS 存储,长期稳定低成本。',
url: 'https://dashi.aliyun.com/activity/oss?userCode=yzrcloud',
skillSlug: null,
toolName: 'WPS AI',
tags: '存储,对象存储',
estimatedCommission: 8,
isFeatured: false,
},
];
for (const link of links) {
let skillId: string | null = null;
if (link.skillSlug) {
const skill = await prisma.skill.findUnique({ where: { id: link.skillSlug } });
skillId = skill?.id ?? null;
}
let toolId: number | null = null;
if (link.toolName) {
const tool = await prisma.tool.findFirst({ where: { name: link.toolName } });
toolId = tool?.id ?? null;
}
await prisma.affiliateLink.upsert({
where: { title: link.title },
update: { url: link.url, active: true, skillId, toolId },
create: {
programId: program.id,
title: link.title,
description: link.description,
url: link.url,
tags: link.tags,
estimatedCommission: link.estimatedCommission,
isFeatured: link.isFeatured,
skillId,
toolId,
active: true,
},
});
}
const count = await prisma.affiliateLink.count();
console.log(`✅ 联盟返佣种子完成,当前推广链接 ${count}`);
}
main()
.catch((e) => {
console.error(e);
process.exit(1);
})
.finally(async () => {
await prisma.$disconnect();
});
+399
View File
@@ -0,0 +1,399 @@
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. Tools (6 flagship, upsert by name) =====
console.log('Seeding tools...');
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) {
const existing = await prisma.tool.findFirst({ where: { name: t.name } });
if (existing) {
await prisma.tool.update({ where: { id: existing.id }, data: t });
} else {
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(); });
+131
View File
@@ -0,0 +1,131 @@
import { PrismaClient } from '@prisma/client';
const prisma = new PrismaClient();
async function main() {
console.log('Expanding content...\n');
// ===== 1. Add more prompts (to reach 30+) =====
console.log('Seeding additional prompts...');
const catIds: Record<string, number> = {};
const cats = await prisma.category.findMany();
for (const c of cats) {
catIds[c.slug] = c.id;
}
const additionalPrompts = [
// Creative Design
{ title: 'Logo 设计需求说明生成', content: '请帮我生成一份 AI Logo 设计需求说明。\n\n品牌名称:[品牌名]\n行业:[行业]\n品牌调性:[现代/传统/科技/自然]\n偏好颜色:[颜色/风格]\n\n请输出:\n1. 3 个设计方向关键词\n2. 详细的设计需求描述(供设计师/Midjourney 使用)\n3. 参考风格建议\n4. 避免的设计风格', description: '用 AI 生成专业的 Logo 设计需求说明', categoryId: catIds['creative-design'], tags: '设计,Logo,品牌', model: '通用', status: 'PUBLISHED' as const },
{ title: '视频脚本生成器', content: '请为我生成一个短视频脚本。\n\n主题:[视频主题]\n目标受众:[描述受众]\n视频时长:[15秒/30秒/60秒]\n风格:[教育/搞笑/感人/促销]\n平台:[抖音/小红书/YouTube Shorts]\n\n脚本结构:\n1. 前 3 秒钩子(吸引注意力)\n2. 正文内容(3-5 个要点)\n3. 结尾 Call-to-Action\n4. 画面描述和字幕建议', description: 'AI 帮你写短视频脚本', categoryId: catIds['creative-design'], tags: '视频,脚本,短视频,抖音', model: 'GPT-5', status: 'PUBLISHED' as const },
{ title: '配色方案生成器', content: '请根据以下信息生成配色方案。\n\n品牌/项目:[名称]\n行业:[行业]\n基调:[活泼/专业/奢华/清新]\n主要受众:[用户群体]\n\n请生成:\n1. 主色(1 个 HEX 色值)\n2. 辅助色(2-3 个)\n3. 强调色(1-2 个)\n4. 背景色(1-2 个)\n5. 文字色(深色/浅色各 1 个)\n6. 这套方案的心理学依据', description: 'AI 帮你设计配色方案', categoryId: catIds['creative-design'], tags: '设计,配色,UI', model: '通用', status: 'PUBLISHED' as const },
// Office Productivity
{ title: '会议议程生成器', content: '请帮我制定一个[会议主题]的议程。\n\n会议主题:[描述]\n参会人员:[角色列表]\n会议时长:[多少分钟]\n会议目标:[要达成的目标]\n\n请输出:\n1. 会议目标(清晰可衡量)\n2. 时间分配表(每个环节的时长和负责人)\n3. 前置准备工作\n4. 预期产出\n5. 跟进方式', description: 'AI 帮你制定高效的会议议程', categoryId: catIds['office-productivity'], tags: '会议,效率,办公', model: '通用', status: 'PUBLISHED' as const },
{ title: 'OKR 制定助手', content: '请帮我制定 OKR(目标和关键结果)。\n\n团队/个人:[名称]\n周期:[2026 Q3/Q4/年度]\n公司目标:[如是团队 OKR,需对齐的上级目标]\n\n请输出:\n1. 2-3 个 Objectives(目标,有挑战性但可达)\n2. 每个 O 下 3-4 个 Key Results(可量化)\n3. 每个 KR 的信心指数(5/10 → 10/10\n4. KR 的衡量标准', description: 'AI 辅助制定 OKR', categoryId: catIds['office-productivity'], tags: 'OKR,目标管理,效率', model: 'Claude 4', status: 'PUBLISHED' as const },
{ title: '项目复盘模板', content: '请根据以下信息生成项目复盘报告模板。\n\n项目名称:[项目名]\n项目周期:[起止时间]\n团队成员:[角色列表]\n\n请按以下结构输出复盘框架:\n1. 项目概况(目标、范围、里程碑)\n2. 数据回顾(完成度、进度偏差、质量指标)\n3. 亮点(What went well\n4. 改进点(What could be improved\n5. 行动计划(具体负责人和截止时间)\n6. 经验沉淀(可复用的最佳实践)', description: 'AI 帮你做项目复盘', categoryId: catIds['office-productivity'], tags: '项目,复盘,管理', model: '通用', status: 'PUBLISHED' as const },
// Programming
{ title: 'Python 脚本生成助手', content: '请帮我写一个 Python 脚本来完成以下任务。\n\n任务描述:[详细描述]\n输入格式:[CSV/JSON/...]\n输出格式:[CSV/JSON/打印/...]\n依赖要求:[允许/不允许使用哪些库]\n\n请生成:\n1. 完整的 Python 脚本\n2. 使用方法和参数说明\n3. 错误处理逻辑\n4. 测试用例示例', description: 'AI 帮你写 Python 工具脚本', categoryId: catIds['programming'], tags: 'Python,脚本,自动化', model: 'Claude 4', status: 'PUBLISHED' as const },
{ title: 'React 组件生成器', content: '请帮我生成一个 React 组件。\n\n组件描述:[组件功能]\nProps[输入参数列表]\nUI 框架:[shadcn/ui / Material UI / Antd / 无]\n状态管理:[useState / zustand / redux / 无]\n\n请生成:\n1. 完整的组件代码(TypeScript\n2. Props 类型定义\n3. 使用示例\n4. 边缘情况处理', description: 'AI 帮你生成 React 组件', categoryId: catIds['programming'], tags: 'React,组件,前端,TypeScript', model: 'Claude 4', status: 'PUBLISHED' as const },
{ title: 'Git 命令顾问', content: '请告诉我如何用 Git 完成以下操作。\n\n我需要:[描述你要用 Git 做什么,如:撤销上 3 次提交但保留工作区修改]\n\n请给出:\n1. 具体的 Git 命令序列\n2. 每个命令的作用\n3. 是否有风险(如:会丢失未提交的修改)\n4. 更安全的替代方案(如果有)', description: 'AI Git 命令顾问', categoryId: catIds['programming'], tags: 'Git,版本控制,开发', model: '通用', status: 'PUBLISHED' as const },
// Prompt Engineering
{ title: 'Prompt 优化器', content: '请帮我优化以下提示词,使其效果更好。\n\n原始提示词:\n[粘贴你的原始提示词]\n\n使用场景:[描述你用它做什么]\n目前问题:[效果不好/结果不稳定/格式不对]\n\n请输出:\n1. 优化后的提示词\n2. 做了哪些改进(角色设定/格式约束/示例添加等)\n3. 为什么这样改有效\n4. 备选方案', description: 'AI 帮你优化提示词', categoryId: catIds['prompt-engineering'], tags: 'Prompt,提示词,优化', model: 'GPT-5', status: 'PUBLISHED' as const },
{ title: '结构化输出生成器', content: '请生成一个提示词模板,让 AI 总是按指定结构输出。\n\n任务:[AI 要完成的任务]\n输出字段:\n- [字段1]:[类型和说明]\n- [字段2]:[类型和说明]\n- [字段3][类型和说明]\n\n请生成:\n1. 完整的系统提示词(System Prompt\n2. 输出格式定义(JSON Schema 或 Markdown 模板)\n3. 示例输出\n4. 处理 AI 不遵循格式的降级策略', description: 'AI 帮你设计结构化输出模板', categoryId: catIds['prompt-engineering'], tags: 'Prompt,结构化,JSON,输出', model: 'Claude 4', status: 'PUBLISHED' as const },
{ title: 'Prompt 链设计器', content: '请帮我设计一个多步骤的 Prompt 链(Chain of Prompts)。\n\n最终目标:[描述最终要完成的任务]\n\n请设计:\nStep 1: [分析/理解] - 提示词 + 预期输出\nStep 2: [规划/构思] - 提示词 + 预期输出\nStep 3: [生成/执行] - 提示词 + 预期输出\nStep 4: [优化/完善] - 提示词 + 预期输出\n\n每个 Step 包含:\n- 提示词内容\n- 输入来源(上一步的输出)\n- 输出格式\n- 质量检查方式', description: 'AI 设计多步骤 Prompt 工作流', categoryId: catIds['prompt-engineering'], tags: 'Prompt链,工作流,高级', model: 'GPT-5', status: 'PUBLISHED' as const },
// AI Basics category
{ title: 'AI 工具选型指南', content: '请帮我选择最适合的 AI 工具。\n\n使用场景:[描述你要用 AI 做什么]\n预算:[每月预算]\n技术能力:[无/基础/中级/高级]\n主要需求:[功能列表]\n\n请推荐:\n1. 最佳选择(推荐工具 + 理由)\n2. 性价比选择(如果预算有限)\n3. 免费替代方案\n4. 多工具组合建议', description: 'AI 帮你选合适的 AI 工具', categoryId: catIds['ai-basics'], tags: 'AI,选型,工具推荐', model: '通用', status: 'PUBLISHED' as const },
{ title: 'AI 风险自查表', content: '请帮我评估使用 [AI 工具/模型] 的潜在风险。\n\n使用的 AI:\n用途:\n涉及数据类型:[公开/内部/机密/个人隐私]\n\n请评估以下风险:\n1. 数据隐私风险(数据是否会上传到第三方)\n2. 输出可靠性风险(幻觉倾向)\n3. 合规风险(是否符合行业法规)\n4. 供应商锁定风险\n5. 降低风险的措施建议', description: 'AI 使用风险评估', categoryId: catIds['ai-basics'], tags: 'AI,风险,安全,合规', model: 'Claude 4', status: 'PUBLISHED' as const },
{ title: 'AI 学习路径规划', content: '请帮我制定一个 AI 学习路径。\n\n当前水平:[零基础/有编程基础/有 ML 基础]\n学习目标:[想成为 AI 产品经理/AI 工程师/AI 应用开发者]\n可用时间:[每周多少小时]\n学习周期:[几个月]\n\n请输出:\n1. 按月划分的学习路线图\n2. 每月核心学习目标\n3. 推荐课程/资源(优先免费)\n4. 实践项目建议\n5. 常见误区提醒', description: 'AI 帮你规划 AI 学习路径', categoryId: catIds['ai-basics'], tags: 'AI,学习,路径,教程', model: '通用', status: 'PUBLISHED' as const },
// Education
{ title: '教案生成助手', content: '请帮我生成一份教案。\n\n课程主题:[主题]\n适用年级/对象:[年级或人群]\n课时长度:[多少分钟]\n学生基础:[已有知识]\n\n教案结构:\n1. 教学目标(知识/技能/素养三个维度)\n2. 教学重难点\n3. 教学过程(导入→新授→练习→总结,含时间分配)\n4. 教学资源清单\n5. 评价方式\n6. 备选活动(如果时间有余)', description: 'AI 帮你写教案', categoryId: catIds['education'], tags: '教育,教案,教学', model: 'GPT-5', status: 'PUBLISHED' as const },
{ title: '考试题生成器', content: '请根据以下内容生成考试题目。\n\n知识点:[列出知识点]\n题型:[选择题/填空题/简答题/编程题]\n难度:[简单/中等/困难]\n题量:[多少道]\n对象:[学生/求职者/培训学员]\n\n要求:\n1. 每道题附参考答案\n2. 选择题需 4 个选项\n3. 标明每题考察的知识点\n4. 简单:中等:困难 = 3:5:2 的比例', description: 'AI 自动生成考试题目', categoryId: catIds['education'], tags: '教育,考试,题目', model: 'Claude 4', status: 'PUBLISHED' as const },
{ title: '论文摘要改写', content: '请帮我改写/润色以下论文摘要。\n\n原文摘要:\n[粘贴摘要]\n\n目标期刊:[期刊名称/风格]\n字数限制:[多少字]\n是否需要突出创新点:[是/否]\n\n请输出:\n1. 改写后的摘要\n2. 改写说明(改了什么、为什么改)\n3. 关键词建议(3-5 个)\n4. 投稿建议', description: 'AI 帮你改写论文摘要', categoryId: catIds['education'], tags: '论文,学术,写作', model: 'GPT-5', status: 'PUBLISHED' as const },
];
for (const p of additionalPrompts) {
await prisma.prompt.create({ data: p });
}
console.log(`${additionalPrompts.length} additional prompts created`);
// ===== 2. Expand courses to match descriptions =====
console.log('\nSeeding additional course chapters & lessons...');
const [course1, course2, course3] = await prisma.course.findMany({ orderBy: { id: 'asc' } });
// Course 1: Prompt Engineering - Add practice chapters
const course1Ch1 = await prisma.chapter.findFirst({ where: { courseId: course1.id, sortOrder: 1 } });
const course1Ch2 = await prisma.chapter.findFirst({ where: { courseId: course1.id, sortOrder: 2 } });
if (course1Ch2) {
// Add Chapter 3: 实战案例(写作场景)
const ch3 = await prisma.chapter.create({
data: { courseId: course1.id, title: '第三章:写作场景实战', sortOrder: 3, lessons: { create: [
{ title: 'AI 辅助博客文章写作', content: '# AI 辅助博客文章写作\n\n## 场景描述\n作为技术博主,每周需要产出 2-3 篇高质量文章。AI 可以帮助你完成选题、提纲、初稿和润色全流程。\n\n## Prompt 模板\n\n```\n你是一位资深技术博主,擅长用通俗易懂的语言解释复杂概念。\n\n请帮我写一篇关于[主题]的博客文章。\n\n目标读者:[描述读者背景]\n文章目的:[教学/观点/教程]\n字数:约 1500 字\n\n要求:\n1. 开头用一个引人入胜的问题或故事\n2. 正文分 3-4 个小节,每节有小标题\n3. 包含实际的代码/案例\n4. 结尾有总结和行动建议\n```\n\n## 实战技巧\n\n1. **选题阶段**:让 AI 帮你想 10 个选题,选最有共鸣的\n2. **提纲阶段**:先让 AI 出提纲,你调整后再写正文\n3. **润色阶段**:完成初稿后,让 AI 优化可读性和 SEO\n\n## 练习\n\n用上面的模板,让 AI 写一篇"2026 年 AI 编程工具对比"的博客文章。', sortOrder: 1, status: 'PUBLISHED' as const },
{ title: 'AI 辅助营销文案写作', content: '# AI 辅助营销文案写作\n\n## 场景描述\n营销文案的 ROI 直接取决于文案质量。AI 可以帮助你快速生成 A/B 测试文案、优化转化率。\n\n## 核心框架:AIDA\n\n- **Attention**(注意):吸引眼球的标题\n- **Interest**(兴趣):让读者产生兴趣\n- **Desire**(欲望):激发购买欲望\n- **Action**(行动):明确的 Call-to-Action\n\n## Prompt 模板\n\n```\n你是一位资深文案撰稿人,擅长 AIDA 框架。\n\n产品:[产品名]\n目标用户:[用户画像]\n核心卖点:[3 个卖点]\n\n请用 AIDA 框架写一篇推广文案:\n1. 3 个备选标题(制造好奇或紧迫感)\n2. 正文(200 字,引发情感共鸣)\n3. 行动号召(紧迫感 + 低门槛)\n```\n\n## 练习\n\n选择一款你熟悉的产品,用 AIDA 框架让 AI 生成 3 版不同风格的推广文案。', sortOrder: 2, status: 'PUBLISHED' as const },
]}},
});
console.log(` ✓ Added chapter "写作场景实战" to "${course1.title}"`);
// Chapter 4: 实战案例(编程与分析)
const ch4 = await prisma.chapter.create({
data: { courseId: course1.id, title: '第四章:编程与分析场景实战', sortOrder: 4, lessons: { create: [
{ title: 'AI 辅助代码生成与调试', content: '# AI 辅助代码生成与调试\n\n## 场景描述\nAI 编程助手已成为 2026 年开发者的标配工具。掌握与 AI 协作编程的技巧,可以提升 2-3 倍开发效率。\n\n## Prompt 模式\n\n### 模式 1:从零生成\n```\n请用 [语言] 写一个 [功能]。[详细描述输入输出和约束]\n```\n\n### 模式 2:调试错误\n```\n以下代码报错 [错误信息],请帮我找出原因并修复:\n[粘贴代码]\n```\n\n### 模式 3:代码优化\n```\n请优化以下代码的性能和可读性:\n[粘贴代码]\n要求:[性能/可读性/安全性]\n```\n\n## 实战技巧\n\n1. **小步迭代**:每次只让 AI 写一个函数,而不是整个系统\n2. **明确约束**:说明语言版本、框架、性能要求\n3. **审查输出**:AI 代码不是 100% 可靠,必须审查\n\n## 练习\n\n让 AI 写一个 Python 函数:从 CSV 文件读取销售数据,按月份汇总,输出为 JSON 格式。', sortOrder: 1, status: 'PUBLISHED' as const },
{ title: 'AI 辅助数据分析报告', content: '# AI 辅助数据分析报告\n\n## 场景描述\n数据分析是 AI 最擅长的领域之一。用自然语言就能完成过去需要半天才能完成的数据分析工作。\n\n## 工作流\n\n1. **数据导入**:上传 CSV/Excel 或粘贴数据\n2. **AI 探索**:"分析这份数据的总体趋势"、"找出异常值"\n3. **深度分析**"按地区分组计算增长率"\n4. **可视化**"生成趋势图,按季度分面"\n5. **报告生成**:"写一份数据分析报告,包含关键发现和建议"\n\n## Prompt 模板\n\n```\n你是一位数据分析师。以下是 [数据集描述] 的数据:\n\n[粘贴数据或描述]\n\n请帮我:\n1. 数据概览(行数、列数、缺失值、基本统计量)\n2. 发现 3 个最重要的洞察\n3. 给出 2 个可执行的建议\n4. 如果要可视化,推荐哪种图表类型\n```\n\n## 练习\n\n找一份你手头的数据集,让 AI 生成分析报告。注意对比 AI 的分析结果和你的直觉判断是否一致。', sortOrder: 2, status: 'PUBLISHED' as const },
]}},
});
console.log(` ✓ Added chapter "编程与分析场景实战" to "${course1.title}"`);
}
// Course 2: AI Office - Add missing meeting & email chapters
const course2Ch2 = await prisma.chapter.findFirst({ where: { courseId: course2.id, sortOrder: 2 } });
if (course2Ch2) {
// Add Chapter 3: 会议管理
const ch3 = await prisma.chapter.create({
data: { courseId: course2.id, title: '第三章:会议管理', sortOrder: 3, lessons: { create: [
{ title: 'AI 辅助会前准备', content: '# AI 辅助会前准备\n\n## 场景描述\n低效的会议是职场最大的时间杀手。AI 可以帮助你做好会前准备,让会议时间减半、效率翻倍。\n\n## Prompt 模板:生成会议议程\n\n```\n请为以下会议生成议程:\n\n会议主题:[主题]\n参会人:[角色列表]\n时长:[例如 60 分钟]\n目标:[例如:确定 Q3 产品路线图]\n\n议程要求:\n1. 每个环节标注时间(含 buffer)\n2. 标注每个环节的负责人\n3. 标注前置准备工作\n4. 标注期望产出\n```\n\n## 其他会前任务\n\n1. **背景资料总结**:让 AI 总结上次会议的纪要\n2. **参会人角色分析**:让 AI 分析各参会人的立场和关注点\n3. **问题预演**:让 AI 模拟可能的质疑和提问\n\n## 练习\n\n用模板为你的下一次团队周会生成议程,然后对比实际会议效果。', sortOrder: 1, status: 'PUBLISHED' as const },
{ title: 'AI 会中记录与会后跟进', content: '# AI 会中记录与会后跟进\n\n## 场景描述\n好的会议记录是团队执行力的保障。AI 可以将会议录音转文字、提取行动项、自动生成会议纪要。\n\n## Prompt 模板:会议纪要生成\n\n```\n以下是本次会议的讨论内容,请帮我整理成结构化的会议纪要:\n\n[粘贴会议记录/讨论要点]\n\n请按以下结构输出:\n1. 会议信息(时间、参会人、主题)\n2. 关键讨论(每个议题的讨论摘要)\n3. 达成的共识(各方一致同意的结论)\n4. 行动项(负责人、截止时间、交付物)\n5. 下次会议计划(时间、议题)\n```\n\n## 跟进技巧\n\n1. 会后 24 小时内发出纪要\n2. 行动项标注优先级(P0/P1/P2)\n3. 下次会议前回顾上次行动项的完成情况\n\n## 练习\n\n在你下次会议后,用 AI 生成会议纪要,看是否比你手写的更完整。', sortOrder: 2, status: 'PUBLISHED' as const },
]}},
});
console.log(` ✓ Added chapter "会议管理" to "${course2.title}"`);
// Chapter 4: 邮件处理
const ch4 = await prisma.chapter.create({
data: { courseId: course2.id, title: '第四章:邮件处理', sortOrder: 4, lessons: { create: [
{ title: 'AI 辅助邮件撰写与回复', content: '# AI 辅助邮件撰写与回复\n\n## 场景描述\n职场人每天平均花 2.5 小时处理邮件。AI 可以帮助你将邮件处理时间缩短 50% 以上。\n\n## Prompt 模板\n\n### 写邮件\n```\n请帮我写一封 [目的] 邮件。\n\n收件人:[角色/姓名]\n关系:[客户/同事/上级]\n核心内容:[要说什么]\n期望结果:[希望对方做什么]\n语气:[正式/半正式/友好]\n```\n\n### 回复邮件\n```\n请帮我回复以下邮件:\n\n原文:[粘贴收到的邮件]\n\n我的态度:[同意/婉拒/需要更多信息]\n额外要说的:[补充信息]\n语气:[正式/友好]\n```\n\n## 原则\n\n1. **明确主题行**:让收件人一眼知道邮件核心\n2. **结论先行**:第一句就说重点\n3. **行动项清晰**:明确告诉对方下一步\n4. **简洁**:能用 5 句说清不用 10 句', sortOrder: 1, status: 'PUBLISHED' as const },
{ title: 'AI 辅助邮件批量处理与分类', content: '# AI 辅助邮件批量处理与分类\n\n## 场景描述\n每天收到几十封邮件,哪些需要立即回复?哪些可以稍后处理?AI 可以帮助你分类和排序。\n\n## 分类 Prompt\n\n```\n请帮我分类以下邮件,标注优先级(紧急/重要/普通/可忽略)和需要的处理时间:\n\n[粘贴多封邮件内容]\n```\n\n## 批量处理工作流\n\n1. **批量分类**:让 AI 按紧急度排序所有未读邮件\n2. **自动起草**:让 AI 为每个分类生成回复草稿\n3. **人工审核**:只需要审核关键邮件,其余直接发送\n\n## 邮件自动化技巧\n\n1. 创建常用回复模板(请假、感谢、确认等)\n2. 让 AI 学习你的回复风格,保持一致性\n3. 用 AI 检查邮件语气,避免误发敏感内容\n\n## 练习\n\n整理你本周收到的 10 封邮件,让 AI 分类并排序,看是否符合你的判断。', sortOrder: 2, status: 'PUBLISHED' as const },
]}},
});
console.log(` ✓ Added chapter "邮件处理" to "${course2.title}"`);
}
// Course 3: AI Programming - Add advanced practice chapter
const course3Ch1 = await prisma.chapter.findFirst({ where: { courseId: course3.id, sortOrder: 1 } });
if (course3Ch1) {
const ch2 = await prisma.chapter.create({
data: { courseId: course3.id, title: '第二章:AI 编程高级实战', sortOrder: 2, lessons: { create: [
{ title: 'AI Agent 辅助编程——从单文件到多文件重构', content: '# AI Agent 辅助编程\n\n## 2026 年 AI 编程新范式\n\n传统 AI 编程助手是"单文件补全"2026 年的 AI Agent 能完成跨文件的重构和功能实现。\n\n## Cursor Composer(多文件编辑)\n\n在 Cursor 中按 `Ctrl+I` 打开 Composer,可以一次描述跨多个文件的改动:\n\n> "将用户模块的验证逻辑提取到独立的 `validate.ts` 文件中,更新所有引用"\n\nComposer 会自动:\n1. 创建新文件\n2. 修改引用文件\n3. 保持类型安全\n\n## Agent Mode 使用技巧\n\n1. **任务拆分**:把一个功能拆成 3-5 个原子任务\n2. **逐步确认**:每完成一个任务检查结果\n3. **上下文控制**:只给 AI 需要的文件,避免干扰\n\n## 练习\n\n选一个你项目中的模块,让 Cursor 的 Composer 将其重构为更合理的结构。', sortOrder: 1, status: 'PUBLISHED' as const },
{ title: 'AI 测试生成与代码审查', content: '# AI 测试生成与代码审查\n\n## AI 生成单元测试\n\n这是提升代码质量的杀手级应用。AI 可以自动生成的测试覆盖边界情况和异常流程。\n\n## Prompt 模板\n\n```\n请为以下函数生成全面的单元测试:\n\n[粘贴函数代码]\n\n要求:\n1. 测试正常路径\n2. 测试边界条件(空值、极限值)\n3. 测试错误处理\n4. 使用 [Jest/Vitest] 框架\n5. 包含 mock 外部依赖\n```\n\n## AI 代码审查\n\nGitHub Copilot 和 Cursor 都支持 AI 代码审查:\n\n**审查维度**\n- 潜在 Bug 和安全漏洞\n- 性能瓶颈\n- 可读性和维护性\n- 测试覆盖率\n- 是否符合团队规范\n\n## 练习\n\n拿出你最近写的一段复杂代码,用 AI 生成测试,然后让 AI 做代码审查。对比 AI 发现的你和 AI 自己错过的点。', sortOrder: 2, status: 'PUBLISHED' as const },
]}},
});
console.log(` ✓ Added chapter "AI 编程高级实战" to "${course3.title}"`);
}
// ===== 3. Summary =====
const [promptCount, lessonCount, chapterCount] = await Promise.all([
prisma.prompt.count(),
prisma.lesson.count(),
prisma.chapter.count(),
]);
console.log(`\n✓ Expansion complete!`);
console.log(` Total prompts: ${promptCount}`);
console.log(` Total chapters: ${chapterCount}`);
console.log(` Total lessons: ${lessonCount}`);
}
main().catch((e) => { console.error(e); process.exit(1); });
+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(); });
+111
View File
@@ -0,0 +1,111 @@
import { PrismaClient } from '@prisma/client';
const prisma = new PrismaClient();
async function main() {
console.log('Seeding domestic AI tools with affiliate links...\n');
const toolsCat = await prisma.category.findUnique({ where: { slug: 'ai-tools' } });
if (!toolsCat) {
console.error('Category "ai-tools" not found — run seed-enrich first.');
process.exit(1);
}
const categoryId = toolsCat.id;
const tools = [
{
name: '豆包',
description: '字节跳动推出的 AI 对话助手,国内用户量最大的 AI 应用之一。支持文本生成、图像理解、语音对话等多种交互方式,深度整合抖音生态。',
url: 'https://www.doubao.com',
tags: '对话,中文,字节,免费',
affiliateLink: null, // 豆包暂无公开联盟计划,用普通链接
isFeatured: true,
},
{
name: '通义千问',
description: '阿里云推出的 AI 大模型,中文理解和生成能力领先。深度整合阿里云生态(钉钉、淘宝、阿里云控制台),提供企业级 API 服务。',
url: 'https://tongyi.aliyun.com',
tags: '中文,阿里云,企业级,API',
affiliateLink: 'https://dashi.aliyun.com/activity/oss?userCode=kap6n5tc', // 阿里云云大使
isFeatured: true,
},
{
name: '文心一言',
description: '百度最新版文心大模型,深度整合百度搜索和知识图谱。在中文知识问答和内容生成方面有独特优势,支持插件生态。',
url: 'https://yiyan.baidu.com',
tags: '中文,百度,搜索,知识',
affiliateLink: 'https://cloud.baidu.com/?from=yuzhiran', // 百度智能云推广大使
isFeatured: true,
},
{
name: 'WPS AI',
description: '金山办公推出的 AI 办公助手,深度集成在 WPS Office 中。支持 AI 写文档、做 PPT、分析表格、生成会议纪要等办公场景。',
url: 'https://ai.wps.cn',
tags: '办公,文档,PPT,效率',
affiliateLink: null, // WPS 会员推广计划
isFeatured: true,
},
{
name: 'Canva',
description: '全球领先的在线设计平台,AI 驱动的设计工具。支持 AI 生成图片、设计模板、品牌套件等,适合非设计师快速创建专业视觉内容。',
url: 'https://www.canva.cn',
tags: '设计,模板,AI 生成,可视化',
affiliateLink: 'https://www.canva.cn/join/yuzhiran', // Canvassador 计划
isFeatured: true,
},
{
name: '腾讯元宝',
description: '腾讯推出的 AI 助手应用,整合腾讯生态(微信、QQ、腾讯文档)。支持多轮对话、内容创作、代码辅助等功能。',
url: 'https://yuanbao.tencent.com',
tags: '对话,腾讯,生态,免费',
affiliateLink: 'https://cloud.tencent.com/act/cps?from=yuzhiran', // 腾讯云推广大使
isFeatured: true,
},
{
name: '讯飞星火',
description: '科大讯飞推出的 AI 认知大模型,在语音识别和自然语言处理方面有深厚积累。支持多模态交互,适合教育、办公场景。',
url: 'https://xinghuo.xfyun.cn',
tags: '语音,教育,办公,讯飞',
affiliateLink: 'https://www.xfyun.cn/?from=yuzhiran',
isFeatured: true,
},
{
name: '剪映专业版',
description: '字节跳动推出的视频剪辑工具,集成 AI 视频生成、智能抠图、自动字幕等 AI 功能。国内最流行的视频创作平台。',
url: 'https://www.capcut.cn',
tags: '视频,剪辑,AI,创作',
affiliateLink: null,
isFeatured: false,
},
];
for (const t of tools) {
const existing = await prisma.tool.findFirst({ where: { name: t.name } });
if (existing) {
await prisma.tool.update({
where: { id: existing.id },
data: {
...t,
categoryId,
status: 'PUBLISHED',
},
});
console.log(` ✓ Updated: ${t.name}`);
} else {
await prisma.tool.create({
data: {
...t,
categoryId,
status: 'PUBLISHED',
},
});
console.log(` + Created: ${t.name}`);
}
}
console.log(`\n✓ ${tools.length} domestic AI tools seeded successfully!`);
}
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
+70
View File
@@ -0,0 +1,70 @@
import 'dotenv/config';
import { PrismaClient, ContentStatus } from '@prisma/client';
const prisma = new PrismaClient();
interface LinkInfo {
url: string;
title: string;
description?: string;
}
const links: LinkInfo[] = [
{ url: 'https://www.aliyun.com/minisite/goods?userCode=kap6n5tc', title: '云小站_专享特惠_云产品推荐-阿里云' },
{ url: 'https://www.aliyun.com/benefit/client/cross?userCode=kap6n5tc', title: '云聚 AI 长效权益' },
{ url: 'https://www.aliyun.com/activity/hub/ai-innovation?userCode=kap6n5tc', title: 'AI 加速季,智惠生产力' },
{ url: 'https://opc.aliyun.com/products?utm_content=g_1000413977&userCode=kap6n5tc', title: 'OPCOne Person Company一人公司)创业装备库' },
{ url: 'https://www.aliyun.com/activity/ecs/clawdbot?userCode=kap6n5tc', title: '稳定不贵,不写代码,分钟级部署Hermes/OpenClaw' },
{ url: 'https://dashi.aliyun.com/activity/aigc?userCode=kap6n5tc', title: '一键轻松打造你的专属AI应用' },
{ url: 'https://dashi.aliyun.com/activity/aiagent?userCode=kap6n5tc', title: '一站式轻松搭建企业级 AI Agent' },
{ url: 'https://www.aliyun.com/daily-act/ecs/activity_selection?userCode=kap6n5tc', title: '开年焕新,限时特惠' },
{ url: 'https://wanwang.aliyun.com/website/index?userCode=kap6n5tc', title: '阿里云建站_AI建站_设计师定制建站_系统定制开发-阿里云' },
{ url: 'https://www.aliyun.com/daily-act/ecs/ecs25srdeepseek?userCode=kap6n5tc', title: 'DeepSeek 个人站点-快速部署方案' },
{ url: 'https://www.aliyun.com/activity?userCode=kap6n5tc', title: '活动中心' },
{ url: 'https://dashi.aliyun.com/activity/oss?userCode=kap6n5tc', title: '阿里云存储产品云大使征集令' },
];
function slugFromUrl(urlStr: string): string {
try {
const url = new URL(urlStr);
let path = url.pathname.replace(/^\/|\/$/g, '').replace(/\//g, '-');
if (!path) path = 'home';
return `aliyun-${path}`;
} catch {
return `aliyun-${Math.random().toString(36).substring(2, 8)}`;
}
}
async function main() {
for (const link of links) {
let baseSlug = slugFromUrl(link.url);
let slug = baseSlug;
let counter = 1;
while (true) {
const existing = await prisma.tool.findUnique({ where: { slug } });
if (!existing) break;
slug = `${baseSlug}-${counter++}`;
}
await prisma.tool.create({
data: {
name: link.title,
slug,
url: link.url,
description: '',
status: ContentStatus.PUBLISHED,
tags: '阿里云,云大使',
isFeatured: true,
},
});
console.log(`✅ Created tool: ${slug} -> ${link.title}`);
}
}
main()
.catch((e) => {
console.error('❌ Seed failed:', e);
process.exit(1);
})
.finally(async () => {
await prisma.$disconnect();
});
+24 -20
View File
@@ -1,5 +1,7 @@
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config'; import { ConfigModule } from '@nestjs/config';
import { ThrottlerModule, ThrottlerGuard } from '@nestjs/throttler';
import { APP_GUARD } from '@nestjs/core';
import { PrismaModule } from './prisma/prisma.module'; import { PrismaModule } from './prisma/prisma.module';
import { AuthModule } from './modules/auth/auth.module'; import { AuthModule } from './modules/auth/auth.module';
import { UsersModule } from './modules/users/users.module'; import { UsersModule } from './modules/users/users.module';
@@ -21,31 +23,33 @@ import { EnterpriseModule } from './modules/enterprise/enterprise.module';
import { NotificationModule } from './modules/notifications/notification.module'; import { NotificationModule } from './modules/notifications/notification.module';
import { LearningModule } from './modules/learning/learning.module'; import { LearningModule } from './modules/learning/learning.module';
import { SkillsModule } from './modules/skills/skills.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 { DonationsModule } from './modules/donations/donations.module';
import { RedisThrottlerStorage } from './common/redis-throttler-storage';
@Module({ @Module({
imports: [ imports: [
ConfigModule.forRoot({ isGlobal: true }), ConfigModule.forRoot({ isGlobal: true }),
ThrottlerModule.forRoot({
throttlers: [
{ name: 'short', ttl: 60000, limit: 60 },
{ name: 'medium', ttl: 300000, limit: 200 },
],
storage: new RedisThrottlerStorage(),
}),
PrismaModule, PrismaModule,
AuthModule, AuthModule, UsersModule, CoursesModule, ContentsModule,
UsersModule, PromptsModule, ToolsModule, SandboxModule, OrdersModule,
CoursesModule, AdminModule, PaymentModule, CategoriesModule, DashboardModule,
ContentsModule, SearchModule, ModelsModule, UploadModule, CommunityModule,
PromptsModule, EnterpriseModule, NotificationModule, LearningModule, SkillsModule,
ToolsModule, AiAssistantModule,
SandboxModule, PracticesModule,
OrdersModule, DonationsModule,
AdminModule, ],
PaymentModule, providers: [
CategoriesModule, { provide: APP_GUARD, useClass: ThrottlerGuard },
DashboardModule,
SearchModule,
ModelsModule,
UploadModule,
CommunityModule,
EnterpriseModule,
NotificationModule,
LearningModule,
SkillsModule,
], ],
}) })
export class AppModule {} 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 { join } from 'path';
import helmet from 'helmet'; import helmet from 'helmet';
import * as compression from 'compression'; import * as compression from 'compression';
import * as cookieParser from 'cookie-parser';
import { AppModule } from './app.module'; import { AppModule } from './app.module';
import { GlobalExceptionFilter, LoggingInterceptor } from './common'; import { GlobalExceptionFilter, LoggingInterceptor } from './common';
@@ -15,14 +16,36 @@ async function bootstrap() {
app.use(helmet({ app.use(helmet({
crossOriginResourcePolicy: { policy: 'cross-origin' }, 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.use(compression());
app.useStaticAssets(join(process.cwd(), 'uploads'), { prefix: '/uploads' }); app.useStaticAssets(join(process.cwd(), 'uploads'), { prefix: '/uploads' });
app.setGlobalPrefix('api/v1'); 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({ app.enableCors({
origin: true, origin: (origin, callback) => {
if (!origin || allowedOrigins.includes(origin)) {
callback(null, true);
} else {
callback(null, false);
}
},
credentials: true, credentials: true,
methods: ['GET', 'POST', 'PUT', 'DELETE', 'PATCH'], methods: ['GET', 'POST', 'PUT', 'DELETE', 'PATCH'],
allowedHeaders: ['Content-Type', 'Authorization'], allowedHeaders: ['Content-Type', 'Authorization'],
@@ -49,4 +72,11 @@ async function bootstrap() {
console.log(`API 文档: http://localhost:${port}/api/docs`); 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(); bootstrap();
+367 -5
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 { ApiTags, ApiBearerAuth } from '@nestjs/swagger';
import { AuthGuard } from '@nestjs/passport'; import { AuthGuard } from '@nestjs/passport';
import { AdminService } from './admin.service'; import { AdminService } from './admin.service';
@@ -7,6 +7,7 @@ import { CoursesService } from '../courses/courses.service';
import { PrismaService } from '../../prisma/prisma.service'; import { PrismaService } from '../../prisma/prisma.service';
import { AIGatewayService } from '../ai/ai-gateway.service'; import { AIGatewayService } from '../ai/ai-gateway.service';
import { AdminAiAssistantService } from './ai-assistant.service'; import { AdminAiAssistantService } from './ai-assistant.service';
import { GatewayPayService } from '../payment/gateway-pay.service';
@ApiTags('管理后台') @ApiTags('管理后台')
@Controller('admin') @Controller('admin')
@@ -17,6 +18,7 @@ export class AdminController {
private prisma: PrismaService, private prisma: PrismaService,
private aiGateway: AIGatewayService, private aiGateway: AIGatewayService,
private aiAssistant: AdminAiAssistantService, private aiAssistant: AdminAiAssistantService,
private gatewayPay: GatewayPayService,
) {} ) {}
@Post('login') @Post('login')
@@ -34,13 +36,124 @@ export class AdminController {
@Get('orders') @Get('orders')
@UseGuards(AuthGuard('jwt'), AdminGuard) @UseGuards(AuthGuard('jwt'), AdminGuard)
@ApiBearerAuth() @ApiBearerAuth()
async orders(@Req() req: any) { async orders(
const items = await this.prisma.order.findMany({ @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' }, orderBy: { createdAt: 'desc' },
take: 100, skip,
take: ps,
include: { user: { select: { id: true, nickname: true, phone: true } } }, 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') @Put('courses/:id/chapters')
@@ -119,4 +232,253 @@ export class AdminController {
return { reply: result.summary, result }; 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 { CoursesModule } from '../courses/courses.module';
import { AuthModule } from '../auth/auth.module'; import { AuthModule } from '../auth/auth.module';
import { AIModule } from '../ai/ai.module'; import { AIModule } from '../ai/ai.module';
import { PaymentModule } from '../payment/payment.module';
@Module({ @Module({
imports: [CoursesModule, AuthModule, AIModule], imports: [CoursesModule, AuthModule, AIModule, PaymentModule],
controllers: [AdminController, AnalyticsController, SettingsController, OperationsController, UsersController, PublicController], controllers: [AdminController, AnalyticsController, SettingsController, OperationsController, UsersController, PublicController],
providers: [AdminService, AdminGuard, AdminAiAssistantService], providers: [AdminService, AdminGuard, AdminAiAssistantService],
exports: [AdminService], 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: '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: {}, 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}` }; 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': { case 'get-enterprise-orgs': {
const items = await this.prisma.organization.findMany({ take: 50, orderBy: { createdAt: 'desc' } }); const items = await this.prisma.organization.findMany({ take: 50, orderBy: { createdAt: 'desc' } });
return { success: true, data: { items, total: items.length }, summary: `${items.length} 个企业组织` }; 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') { if (type === 'users') {
const data = await this.prisma.$queryRaw<{ date: string; count: bigint }[]>` const data = await this.prisma.$queryRaw<{ date: string; count: bigint }[]>`
SELECT DATE(created_at) as date, COUNT(*) as count SELECT DATE(created_at) as date, COUNT(*) as count
FROM User FROM users
WHERE deleted_at IS NULL AND created_at >= ${startDate} WHERE deleted_at IS NULL AND created_at >= ${startDate}
GROUP BY DATE(created_at) GROUP BY DATE(created_at)
ORDER BY date ORDER BY date
@@ -149,7 +149,7 @@ export class AnalyticsController {
if (type === 'orders') { if (type === 'orders') {
const data = await this.prisma.$queryRaw<{ date: string; count: bigint; revenue: bigint }[]>` 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 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} WHERE status = 'PAID' AND created_at >= ${startDate}
GROUP BY DATE(created_at) GROUP BY DATE(created_at)
ORDER BY date ORDER BY date
@@ -160,7 +160,7 @@ export class AnalyticsController {
if (type === 'sessions') { if (type === 'sessions') {
const data = await this.prisma.$queryRaw<{ date: string; count: bigint }[]>` const data = await this.prisma.$queryRaw<{ date: string; count: bigint }[]>`
SELECT DATE(created_at) as date, COUNT(*) as count SELECT DATE(created_at) as date, COUNT(*) as count
FROM sandbox_session FROM sandbox_sessions
WHERE created_at >= ${startDate} WHERE created_at >= ${startDate}
GROUP BY DATE(created_at) GROUP BY DATE(created_at)
ORDER BY date ORDER BY date
+8 -3
View File
@@ -107,13 +107,18 @@ export class OperationsController {
} }
@Put('config/:key') @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 } }); const existing = await this.prisma.systemConfig.findUnique({ where: { key } });
if (existing) { 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({ 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 { export class PublicController {
constructor(private prisma: PrismaService) {} 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') @Get('banners')
async banners(@Query('position') position?: string) { async banners(@Query('position') position?: string) {
const now = new Date(); const now = new Date();
+11 -42
View File
@@ -13,23 +13,12 @@ export class SettingsController {
@Get('roles') @Get('roles')
async roles() { async roles() {
return { return { items: await this.prisma.adminRole.findMany({ where: { status: 'ACTIVE' }, orderBy: { createdAt: 'desc' } }) };
items: await this.prisma.adminRole.findMany({
where: { status: 'ACTIVE' },
orderBy: { createdAt: 'desc' },
}),
};
} }
@Post('roles') @Post('roles')
async createRole(@Body() body: { name: string; description?: string; permissions?: string[] }) { async createRole(@Body() body: { name: string; description?: string; permissions?: string[] }) {
return this.prisma.adminRole.create({ return this.prisma.adminRole.create({ data: { name: body.name, description: body.description || '', permissions: body.permissions || [] } });
data: {
name: body.name,
description: body.description || '',
permissions: body.permissions || [],
},
});
} }
@Put('roles/:id') @Put('roles/:id')
@@ -48,27 +37,14 @@ export class SettingsController {
@Get('admins') @Get('admins')
async admins() { async admins() {
return { return { items: await this.prisma.adminUser.findMany({ where: { status: 'ACTIVE' }, include: { role: true }, orderBy: { createdAt: 'desc' } }) };
items: await this.prisma.adminUser.findMany({
where: { status: 'ACTIVE' },
include: { role: true },
orderBy: { createdAt: 'desc' },
}),
};
} }
@Post('admins') @Post('admins')
async createAdmin(@Body() body: { username: string; password: string; nickname?: string; roleId?: string }) { async createAdmin(@Body() body: { username: string; password: string; nickname?: string; roleId?: string }) {
const bcrypt = require('bcryptjs'); const bcrypt = require('bcryptjs');
const passwordHash = await bcrypt.hash(body.password, 10); const passwordHash = await bcrypt.hash(body.password, 10);
return this.prisma.adminUser.create({ return this.prisma.adminUser.create({ data: { username: body.username, passwordHash, nickname: body.nickname || '', roleId: body.roleId || null } });
data: {
username: body.username,
passwordHash,
nickname: body.nickname || '',
roleId: body.roleId || null,
},
});
} }
@Put('admins/:id') @Put('admins/:id')
@@ -89,20 +65,13 @@ export class SettingsController {
permissions() { permissions() {
return { return {
items: [ items: [
{ key: 'dashboard', name: '仪表盘', category: '首页' }, { key: 'dashboard', name: '仪表盘', category: '首页' }, { key: 'users', name: '用户管理', category: '用户' },
{ key: 'users', name: '用户管理', category: '用户' }, { key: 'courses', name: '课程管理', category: '内容' }, { key: 'prompts', name: '提示词管理', category: '内容' },
{ key: 'courses', name: '课程管理', category: '内容' }, { key: 'contents', name: '内容管理', category: '内容' }, { key: 'tools', name: '工具管理', category: '内容' },
{ key: 'prompts', name: '提示词管理', category: '内容' }, { key: 'orders', name: '订单管理', category: '交易' }, { key: 'enterprise', name: '企业版管理', category: '交易' },
{ key: 'contents', name: '内容管理', category: '内容' }, { key: 'comments', name: '评论审核', category: '社区' }, { key: 'analytics', name: '数据分析', category: '统计' },
{ key: 'tools', name: '工具管理', category: '内容' }, { key: 'roles', name: '角色权限', category: '设置' }, { key: 'admins', name: '管理', category: '设置' },
{ key: 'orders', name: '订单管理', category: '交易' }, { key: 'config', name: '系统配置', category: '设置' }, { key: 'banners', name: 'Banner管理', 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: '运营' }, { key: 'notifications', name: '推送管理', category: '运营' },
], ],
}; };
+1 -1
View File
@@ -51,7 +51,7 @@ describe('AdminService', () => {
id: 1, id: 1,
username: 'admin', username: 'admin',
passwordHash: '$2a$10$hashed', passwordHash: '$2a$10$hashed',
role: 'superadmin', role: { name: 'superadmin' },
status: 'ACTIVE', 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 };
}
}
+124 -23
View File
@@ -21,17 +21,20 @@ export interface ModelInfo {
interface AIProvider { interface AIProvider {
name: string; name: string;
chat(messages: ChatMessage[], options?: ChatOptions): Promise<string>; chat(messages: ChatMessage[], options?: ChatOptions): Promise<string>;
chatStream?(messages: ChatMessage[], options?: ChatOptions): AsyncIterable<string>;
} }
// 仅收录沙箱实际可用的国内已备案大模型
const MODEL_CATALOG: Record<string, ModelInfo> = { const MODEL_CATALOG: Record<string, ModelInfo> = {
'general': { id: 'general', provider: 'OpenAI 兼容', capabilities: ['chat', 'code'], contextWindow: 8192 }, 'deepseek-v4-flash': { id: 'deepseek-v4-flash', provider: '硅基流动/DeepSeek', capabilities: ['chat', 'code'], contextWindow: 32768 },
'openai': { id: 'openai', provider: 'OpenAI 兼容', capabilities: ['chat', 'code'], contextWindow: 8192 },
'gpt-3.5': { id: 'gpt-3.5', provider: 'OpenAI', capabilities: ['chat', 'code'], contextWindow: 16384 },
'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-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 }, };
}
/** 沙箱可选模型列表(前端展示用),modelId 必须与 MODEL_CATALOG 中的 id 一致 */
export const SANDBOX_MODELS = [
{ id: 'deepseek-v4-flash', label: 'DeepSeek V4 Flash', provider: '硅基流动', desc: '高速推理,代码生成能力强,价格极低', available: false },
{ id: 'sensenova-6.7-flash-lite', label: 'SenseNova 6.7 Flash Lite', provider: '商汤科技', desc: '轻量快速,日常对话与文本处理', available: false },
];
@Injectable() @Injectable()
export class AIGatewayService { export class AIGatewayService {
@@ -44,19 +47,21 @@ export class AIGatewayService {
} }
private registerProviders() { private registerProviders() {
// 国内已备案大模型 — 通过硅基流动 / DeepSeek 直连等国内渠道
if (process.env.OPENAI_API_KEY) { if (process.env.OPENAI_API_KEY) {
let apiUrl = process.env.OPENAI_API_URL || 'https://api.openai.com/v1/chat/completions'; let apiUrl = process.env.OPENAI_API_URL || 'https://api.openai.com/v1/chat/completions';
if (!apiUrl.endsWith('/chat/completions')) { if (!apiUrl.endsWith('/chat/completions')) {
apiUrl = apiUrl.replace(/\/+$/, '') + '/chat/completions'; apiUrl = apiUrl.replace(/\/+$/, '') + '/chat/completions';
} }
const defaultModel = process.env.OPENAI_MODEL || 'gpt-3.5-turbo'; const defaultModel = process.env.OPENAI_MODEL || 'deepseek/deepseek-v4-flash';
this.providers.set('openai', new OpenAICompatibleProvider( this.providers.set('deepseek-v4-flash', new OpenAICompatibleProvider(
process.env.OPENAI_API_KEY!, process.env.OPENAI_API_KEY!,
apiUrl, apiUrl,
defaultModel, defaultModel,
'OpenAI 兼容接口', '硅基流动 / DeepSeek',
)); ));
this.logger.log(`OpenAI 兼容接口已注册: ${defaultModel}`); this.logger.log(`DeepSeek(通过国内渠道)已注册: ${defaultModel}`);
this.markAvailable('deepseek-v4-flash');
} }
if (process.env.SENSENOVA_API_KEY) { if (process.env.SENSENOVA_API_KEY) {
@@ -64,33 +69,34 @@ export class AIGatewayService {
if (!apiUrl.endsWith('/chat/completions')) { if (!apiUrl.endsWith('/chat/completions')) {
apiUrl = apiUrl.replace(/\/+$/, '') + '/chat/completions'; apiUrl = apiUrl.replace(/\/+$/, '') + '/chat/completions';
} }
const models = ['deepseek-v4-flash', 'sensenova-6.7-flash-lite', 'sensenova-u1-fast']; // 商汤科技仅注册 sensenova 自有模型,避免覆盖硅基流动的 deepseek 注册
for (const modelName of models) { const modelName = 'sensenova-6.7-flash-lite';
this.providers.set(modelName, new OpenAICompatibleProvider( this.providers.set(modelName, new OpenAICompatibleProvider(
process.env.SENSENOVA_API_KEY!, process.env.SENSENOVA_API_KEY!,
apiUrl, apiUrl,
modelName, modelName,
'商汤科技', '商汤科技',
)); ));
this.markAvailable(modelName);
this.logger.log(`商汤科技已注册: ${modelName}`);
} }
this.logger.log(`商汤科技已注册: ${models.join(', ')}`); }
/** 标记 SANDBOX_MODELS 中的对应模型为可用 */
private markAvailable(modelId: string) {
for (const m of SANDBOX_MODELS) {
if (m.id === modelId) m.available = true;
} }
} }
async chat(model: string, messages: ChatMessage[], options?: ChatOptions): Promise<string> { async chat(model: string, messages: ChatMessage[], options?: ChatOptions): Promise<string> {
// 仅路由到国内已备案模型的 provider
const modelMap: Record<string, 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', 'deepseek-v4-flash': 'deepseek-v4-flash',
'sensenova-6.7-flash-lite': 'sensenova-6.7-flash-lite', '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); const providerKey = modelMap[model.toLowerCase()] || model;
const provider = this.providers.get(providerKey); const provider = this.providers.get(providerKey);
if (!this.usageStats[providerKey]) this.usageStats[providerKey] = { total: 0, success: 0, failed: 0 }; if (!this.usageStats[providerKey]) this.usageStats[providerKey] = { total: 0, success: 0, failed: 0 };
@@ -114,6 +120,47 @@ export class AIGatewayService {
return this.fallback(messages); return this.fallback(messages);
} }
async *chatStream(model: string, messages: ChatMessage[], options?: ChatOptions): AsyncIterable<string> {
const modelMap: Record<string, string> = {
'deepseek-v4-flash': 'deepseek-v4-flash',
'sensenova-6.7-flash-lite': 'sensenova-6.7-flash-lite',
};
const providerKey = modelMap[model.toLowerCase()] || 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 { private fallback(messages: ChatMessage[]): string {
const lastMsg = typeof messages[messages.length - 1]?.content === 'string' const lastMsg = typeof messages[messages.length - 1]?.content === 'string'
? messages[messages.length - 1]?.content as string : ''; ? messages[messages.length - 1]?.content as string : '';
@@ -152,6 +199,11 @@ export class AIGatewayService {
getRegisteredProviders(): string[] { getRegisteredProviders(): string[] {
return Array.from(this.providers.keys()); return Array.from(this.providers.keys());
} }
/** 返回沙箱前端可选的模型列表(仅返回有 provider 注册的模型) */
getSandboxModels() {
return SANDBOX_MODELS.filter(m => m.available);
}
} }
class OpenAICompatibleProvider implements AIProvider { class OpenAICompatibleProvider implements AIProvider {
@@ -188,6 +240,55 @@ class OpenAICompatibleProvider implements AIProvider {
} }
const data = await res.json() as any; 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 () => { beforeEach(async () => {
// Clear env before each test // Clear env before each test
delete process.env.DEEPSEEK_API_KEY; delete process.env.OPENAI_API_KEY;
delete process.env.DASHSCOPE_API_KEY; delete process.env.SENSENOVA_API_KEY;
const module: TestingModule = await Test.createTestingModule({ const module: TestingModule = await Test.createTestingModule({
providers: [AIGatewayService], providers: [AIGatewayService],
@@ -70,8 +70,8 @@ describe('AIGatewayService', () => {
}); });
describe('model routing', () => { describe('model routing', () => {
it('should route deepseek models to deepseek provider (but fallback to mock)', async () => { it('should route deepseek-v4-flash to its provider (but fallback to mock)', async () => {
const result = await service.chat('deepseek-chat', [ const result = await service.chat('deepseek-v4-flash', [
{ role: 'user', content: '你好' }, { role: 'user', content: '你好' },
]); ]);
@@ -79,8 +79,8 @@ describe('AIGatewayService', () => {
expect(result).toContain('宇之然 AI 助手'); expect(result).toContain('宇之然 AI 助手');
}); });
it('should route qwen models to dashscope provider (but fallback to mock)', async () => { it('should route general model to openai provider (but fallback to mock)', async () => {
const result = await service.chat('qwen-max', [ const result = await service.chat('general', [
{ role: 'user', content: '你好' }, { 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 { ApiTags, ApiBearerAuth } from '@nestjs/swagger';
import { AuthGuard } from '@nestjs/passport'; import { AuthGuard } from '@nestjs/passport';
import { Throttle } from '@nestjs/throttler';
import { AuthService } from './auth.service'; import { AuthService } from './auth.service';
import { RegisterDto } from './dto/register.dto'; import { RegisterDto } from './dto/register.dto';
import { LoginDto } from './dto/login.dto';
@ApiTags('认证') @ApiTags('认证')
@Controller('auth') @Controller('auth')
export class AuthController { export class AuthController {
constructor(private authService: AuthService) {} 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') @Post('register')
async register(@Body() body: RegisterDto) { @Throttle({ default: { limit: 3, ttl: 60000 } })
return this.authService.register(body); 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') @Post('login')
async login(@Body() body: { account: string; password: string }) { @Throttle({ default: { limit: 5, ttl: 60000 } })
return this.authService.login(body.account, body.password); 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') @Post('refresh')
async refresh(@Body() body: { accessToken: string }) { @Throttle({ default: { limit: 10, ttl: 60000 } })
return this.authService.refreshAccessToken(body.accessToken); 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') @Get('profile')
@@ -30,4 +52,17 @@ export class AuthController {
async profile(@Req() req: any) { async profile(@Req() req: any) {
return this.authService.getProfile(req.user.userId); 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 { JwtService } from '@nestjs/jwt';
import * as bcrypt from 'bcryptjs'; import * as bcrypt from 'bcryptjs';
import { PrismaService } from '../../prisma/prisma.service'; import { PrismaService } from '../../prisma/prisma.service';
@@ -10,14 +10,32 @@ export class AuthService {
private jwtService: JwtService, 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 passwordHash = await bcrypt.hash(data.password, 10);
const user = await this.prisma.user.create({ const user = await this.prisma.user.create({
data: { data: {
phone: data.phone, username: data.username || null,
email: data.email, phone: data.phone || null,
email: data.email || null,
passwordHash, 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); return this.generateTokens(user.id);
@@ -26,7 +44,7 @@ export class AuthService {
async login(account: string, password: string) { async login(account: string, password: string) {
const user = await this.prisma.user.findFirst({ const user = await this.prisma.user.findFirst({
where: { where: {
OR: [{ phone: account }, { email: account }], OR: [{ username: account }, { phone: account }, { email: account }],
deletedAt: null, deletedAt: null,
}, },
}); });
@@ -68,6 +86,7 @@ export class AuthService {
where: { id: userId }, where: { id: userId },
select: { select: {
id: true, id: true,
username: true,
phone: true, phone: true,
email: true, email: true,
nickname: 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 { export class RegisterDto {
@IsOptional() @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; phone?: string;
@IsOptional() @IsOptional()
@IsString() @Matches(/^[^\s@]+@[^\s@]+\.[^\s@]+$/, { message: '邮箱格式不正确' })
email?: string; email?: string;
@IsNotEmpty({ message: '密码不能为空' }) @IsNotEmpty({ message: '密码不能为空' })
+8 -2
View File
@@ -10,9 +10,15 @@ export class JwtStrategy extends PassportStrategy(Strategy) {
private config: ConfigService, private config: ConfigService,
private prisma: PrismaService, 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({ super({
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(), jwtFromRequest: ExtractJwt.fromExtractors([
ExtractJwt.fromAuthHeaderAsBearerToken(),
(req) => req?.cookies?.token || null,
]),
ignoreExpiration: false, ignoreExpiration: false,
secretOrKey: secret, secretOrKey: secret,
}); });
View File
+9 -2
View File
@@ -1,5 +1,6 @@
import { Controller, Get, Post, Put, Delete, Body, Param } from '@nestjs/common'; import { Controller, Get, Post, Put, Delete, Body, Param, UseGuards } from '@nestjs/common';
import { ApiTags } from '@nestjs/swagger'; import { ApiTags, ApiBearerAuth } from '@nestjs/swagger';
import { AuthGuard } from '@nestjs/passport';
import { PrismaService } from '../../prisma/prisma.service'; import { PrismaService } from '../../prisma/prisma.service';
@ApiTags('分类') @ApiTags('分类')
@@ -21,16 +22,22 @@ export class CategoriesController {
} }
@Post() @Post()
@UseGuards(AuthGuard('jwt'))
@ApiBearerAuth()
async create(@Body() body: { name: string; slug: string; description?: string; sortOrder?: number }) { async create(@Body() body: { name: string; slug: string; description?: string; sortOrder?: number }) {
return this.prisma.category.create({ data: body }); return this.prisma.category.create({ data: body });
} }
@Put(':id') @Put(':id')
@UseGuards(AuthGuard('jwt'))
@ApiBearerAuth()
async update(@Param('id') id: string, @Body() body: any) { async update(@Param('id') id: string, @Body() body: any) {
return this.prisma.category.update({ where: { id: +id }, data: body }); return this.prisma.category.update({ where: { id: +id }, data: body });
} }
@Delete(':id') @Delete(':id')
@UseGuards(AuthGuard('jwt'))
@ApiBearerAuth()
async remove(@Param('id') id: string) { async remove(@Param('id') id: string) {
return this.prisma.category.delete({ where: { id: +id } }); 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 { Controller, Get, Post, Put, Delete, Body, Param, Query, UseGuards } from '@nestjs/common';
import { ApiTags } from '@nestjs/swagger'; import { ApiTags, ApiBearerAuth } from '@nestjs/swagger';
import { AuthGuard } from '@nestjs/passport';
import { ContentsService } from './contents.service'; import { ContentsService } from './contents.service';
@ApiTags('内容') @ApiTags('内容')
@@ -18,16 +19,22 @@ export class ContentsController {
} }
@Post() @Post()
@UseGuards(AuthGuard('jwt'))
@ApiBearerAuth()
async create(@Body() body: any) { async create(@Body() body: any) {
return this.contentsService.create(body); return this.contentsService.create(body);
} }
@Put(':id') @Put(':id')
@UseGuards(AuthGuard('jwt'))
@ApiBearerAuth()
async update(@Param('id') id: string, @Body() body: any) { async update(@Param('id') id: string, @Body() body: any) {
return this.contentsService.update(+id, body); return this.contentsService.update(+id, body);
} }
@Delete(':id') @Delete(':id')
@UseGuards(AuthGuard('jwt'))
@ApiBearerAuth()
async remove(@Param('id') id: string) { async remove(@Param('id') id: string) {
return this.contentsService.remove(+id); return this.contentsService.remove(+id);
} }
View File
View File
+6
View File
@@ -19,6 +19,8 @@ export class CoursesController {
} }
@Post() @Post()
@UseGuards(AuthGuard('jwt'))
@ApiBearerAuth()
async create(@Body() body: { async create(@Body() body: {
title: string; description?: string; cover?: string; categoryId?: number; title: string; description?: string; cover?: string; categoryId?: number;
price?: number; isFree?: boolean; price?: number; isFree?: boolean;
@@ -27,11 +29,15 @@ export class CoursesController {
} }
@Put(':id') @Put(':id')
@UseGuards(AuthGuard('jwt'))
@ApiBearerAuth()
async update(@Param('id') id: string, @Body() body: any) { async update(@Param('id') id: string, @Body() body: any) {
return this.coursesService.update(+id, body); return this.coursesService.update(+id, body);
} }
@Delete(':id') @Delete(':id')
@UseGuards(AuthGuard('jwt'))
@ApiBearerAuth()
async remove(@Param('id') id: string) { async remove(@Param('id') id: string) {
return this.coursesService.remove(+id); return this.coursesService.remove(+id);
} }
View File
View File
View File
View File
View File
View File
@@ -0,0 +1,20 @@
import { Controller, Get, Post, Body, Query, HttpCode } from '@nestjs/common';
import { DonationsService } from './donations.service';
import { CreateDonationDto } from './dto/create-donation.dto';
@Controller('donations')
export class DonationsController {
constructor(private readonly donationsService: DonationsService) {}
@Get()
getList(@Query('limit') limit?: string) {
const parsed = limit ? Math.min(parseInt(limit, 10) || 20, 50) : 20;
return this.donationsService.getList(parsed);
}
@Post()
@HttpCode(200)
create(@Body() dto: CreateDonationDto) {
return this.donationsService.create(dto);
}
}
@@ -0,0 +1,12 @@
import { Module } from '@nestjs/common';
import { DonationsService } from './donations.service';
import { DonationsController } from './donations.controller';
import { PrismaModule } from '../../prisma/prisma.module';
@Module({
imports: [PrismaModule],
controllers: [DonationsController],
providers: [DonationsService],
exports: [DonationsService],
})
export class DonationsModule {}
@@ -0,0 +1,51 @@
import { Injectable } from '@nestjs/common';
import { PrismaService } from '../../prisma/prisma.service';
@Injectable()
export class DonationsService {
constructor(private prisma: PrismaService) {}
async getList(limit = 20) {
const donations = await this.prisma.donation.findMany({
orderBy: { createdAt: 'desc' },
take: limit,
select: {
id: true,
name: true,
message: true,
channel: true,
amount: true,
createdAt: true,
},
});
return {
donations: donations.map((d) => ({
...d,
amount: d.amount != null ? Number(d.amount) : null,
})),
};
}
async create(data: {
name?: string;
message?: string;
channel?: 'ALIPAY' | 'WECHAT';
amount?: number;
}) {
const donation = await this.prisma.donation.create({
data: {
name: data.name ?? null,
message: data.message ?? null,
channel: data.channel ?? 'ALIPAY',
amount: data.amount != null ? data.amount : null,
},
});
return {
success: true,
donation: {
...donation,
amount: donation.amount != null ? Number(donation.amount) : null,
},
};
}
}
@@ -0,0 +1,22 @@
import { IsOptional, IsString, IsIn, IsNumber, MaxLength, Min } from 'class-validator';
export class CreateDonationDto {
@IsOptional()
@IsString()
@MaxLength(40)
name?: string;
@IsOptional()
@IsString()
@MaxLength(280)
message?: string;
@IsOptional()
@IsIn(['ALIPAY', 'WECHAT'])
channel?: 'ALIPAY' | 'WECHAT';
@IsOptional()
@IsNumber({ allowNaN: false, allowInfinity: false })
@Min(0)
amount?: number;
}
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; amount: number;
@IsString() @IsString()
@IsIn(['MONTHLY', 'YEARLY', 'COURSE']) @IsIn(['MONTHLY', 'YEARLY', 'COURSE', 'PACKAGE', 'SKILL'])
planType: string; planType: string;
@IsOptional()
@IsString()
skillId?: string;
@IsOptional() @IsOptional()
@IsString() @IsString()
payChannel?: string; payChannel?: string;
View File
+108 -18
View File
@@ -1,52 +1,142 @@
import { Injectable } from '@nestjs/common'; import { Injectable, Logger } from '@nestjs/common';
import { PrismaService } from '../../prisma/prisma.service'; import { PrismaService } from '../../prisma/prisma.service';
import { PaymentService } from '../payment/payment.service'; import { GatewayPayService } from '../payment/gateway-pay.service';
@Injectable() @Injectable()
export class OrdersService { export class OrdersService {
constructor( constructor(
private prisma: PrismaService, 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 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({ const order = await this.prisma.order.create({
data: { data: {
orderNo, orderNo,
userId, userId,
amount: data.amount, amount: data.amount,
planType: data.planType, 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> = { const planLabels: Record<string, string> = {
MONTHLY: '宇之然AI月卡会员', MONTHLY: '宇之然AI月卡会员',
YEARLY: '宇之然AI年卡会员', YEARLY: '宇之然AI年卡会员',
PACKAGE: '宇之然AI用量包',
SKILL: 'AI技能购买',
}; };
const payResult = await this.paymentService.createUnifiedOrder({ const subject = planLabels[data.planType] || '宇之然AI会员充值';
description: planLabels[data.planType] || '宇之然AI会员充值',
outTradeNo: orderNo, try {
const remark = JSON.stringify({ uid: userId, oid: order.id });
const result = await this.gatewayPay.createOrder({
merchantOrderId: orderNo,
amount: data.amount, amount: data.amount,
tradeType: (data.tradeType as 'JSAPI' | 'NATIVE' | 'MWEB') || 'NATIVE', paymentMethod: channel === 'wxpay' ? 'wechat' : 'alipay',
openid: data.openid, subject,
remark,
});
if (result.gatewayOrderId) {
await this.prisma.order.update({
where: { id: order.id },
data: { gatewayOrderId: result.gatewayOrderId },
}); });
// Mock 模式下自动完成支付闭环(NATIVE mock 无需扫码)
if (payResult?.codeUrl === 'mock://pay') {
await this.paymentService.completeMockPayment(orderNo);
} }
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 }; return { order, payResult };
} catch { } catch (err) {
console.error(`网关下单失败: ${err.message}, 使用模拟支付`);
// 网关失败时,技能订单自动用模拟支付完成,保证用户能购买
if (data.planType === 'SKILL') {
await this.completeMockPayment(order.id);
return {
order,
payResult: { gatewayOrderId: null, payUrl: 'mock://pay', qrcode: 'mock://pay', status: 'paid' },
message: '网关临时不可用,已自动完成支付',
};
}
return { order, payResult: null }; return { order, payResult: null };
} }
} }
return { order }; 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 },
});
}
}
} }
async findByUser(userId: number, params: { page?: number; pageSize?: number }) { 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 { Test, TestingModule } from '@nestjs/testing';
import { OrdersService } from '../orders.service'; import { OrdersService } from '../orders.service';
import { PrismaService } from '../../../prisma/prisma.service'; import { PrismaService } from '../../../prisma/prisma.service';
import { PaymentService } from '../../payment/payment.service'; import { GatewayPayService } from '../../payment/gateway-pay.service';
describe('OrdersService', () => { describe('OrdersService', () => {
let service: OrdersService; let service: OrdersService;
let prisma: PrismaService; let prisma: PrismaService;
let paymentService: PaymentService; let gatewayPay: GatewayPayService;
const mockPrisma = { const mockPrisma = {
order: { order: {
@@ -19,12 +19,18 @@ describe('OrdersService', () => {
subscription: { subscription: {
findFirst: jest.fn(), findFirst: jest.fn(),
findMany: jest.fn(), findMany: jest.fn(),
create: jest.fn(),
},
user: {
update: jest.fn(),
}, },
}; };
const mockPaymentService = { const mockGatewayPay = {
createUnifiedOrder: jest.fn(), createOrder: jest.fn(),
completeMockPayment: jest.fn(), queryOrder: jest.fn(),
refund: jest.fn(),
handleWebhook: jest.fn(),
}; };
beforeEach(async () => { beforeEach(async () => {
@@ -32,74 +38,86 @@ describe('OrdersService', () => {
providers: [ providers: [
OrdersService, OrdersService,
{ provide: PrismaService, useValue: mockPrisma }, { provide: PrismaService, useValue: mockPrisma },
{ provide: PaymentService, useValue: mockPaymentService }, { provide: GatewayPayService, useValue: mockGatewayPay },
], ],
}).compile(); }).compile();
service = module.get<OrdersService>(OrdersService); service = module.get<OrdersService>(OrdersService);
prisma = module.get<PrismaService>(PrismaService); prisma = module.get<PrismaService>(PrismaService);
paymentService = module.get<PaymentService>(PaymentService); gatewayPay = module.get<GatewayPayService>(GatewayPayService);
jest.clearAllMocks(); jest.clearAllMocks();
}); });
describe('create', () => { 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 = { const mockOrder = {
id: 1, id: 1,
orderNo: 'YZR123', orderNo: 'YZR123',
amount: 29.9, amount: 49.9,
planType: 'MONTHLY', planType: 'MONTHLY',
payChannel: 'wxpay', payChannel: 'alipay',
}; };
mockPrisma.order.create.mockResolvedValue(mockOrder); mockPrisma.order.create.mockResolvedValue(mockOrder);
mockPaymentService.createUnifiedOrder.mockResolvedValue({ mockPrisma.order.update.mockResolvedValue(mockOrder);
prepay_id: 'wx123', mockGatewayPay.createOrder.mockResolvedValue({
nonceStr: 'abc', gatewayOrderId: 'gateway_abc',
payUrl: 'https://pay.example.com',
status: 'pending',
}); });
const result = await service.create(1, { const result = await service.create(1, {
amount: 29.9, amount: 49.9,
planType: 'MONTHLY', 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', payChannel: 'wxpay',
}); });
expect(result).toHaveProperty('order'); expect(result).toHaveProperty('order');
expect(result).toHaveProperty('payResult'); expect(result).toHaveProperty('payResult');
expect(mockPrisma.order.create).toHaveBeenCalledWith({ expect(mockGatewayPay.createOrder).toHaveBeenCalledWith(
data: expect.objectContaining({ expect.objectContaining({
userId: 1, paymentMethod: 'wechat',
amount: 29.9, amount: 299,
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();
}); });
}); });
describe('findByUser', () => { describe('findByUser', () => {
it('should return paginated orders for user', async () => { it('should return paginated orders for user', async () => {
const mockOrders = [ const mockOrders = [
{ id: 1, orderNo: 'YZR123', amount: 29.9 } { id: 1, orderNo: 'YZR123', amount: 49.9 }
]; ];
mockPrisma.order.findMany.mockResolvedValue(mockOrders); mockPrisma.order.findMany.mockResolvedValue(mockOrders);
mockPrisma.order.count.mockResolvedValue(1); mockPrisma.order.count.mockResolvedValue(1);
@@ -136,7 +154,7 @@ describe('OrdersService', () => {
userId: 1, userId: 1,
plan: 'YEARLY', plan: 'YEARLY',
status: 'ACTIVE', status: 'ACTIVE',
endDate: new Date(Date.now() + 86400000), // 明天到期 endDate: new Date(Date.now() + 86400000),
}; };
mockPrisma.subscription.findFirst.mockResolvedValue(mockSub); mockPrisma.subscription.findFirst.mockResolvedValue(mockSub);
@@ -0,0 +1,311 @@
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}`);
} else if (order.planType === 'SKILL' && order.metadata) {
// SKILL 订单:激活技能到用户账户
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}`);
}
}
}
}
@@ -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;
}
}

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