feat: Phase 1-3 全部完成 — 沙盒增强、学情分析、学习路径

This commit is contained in:
yuzhiran-dev
2026-05-18 09:48:51 +08:00
commit 11bb86854c
277 changed files with 37755 additions and 0 deletions
+97
View File
@@ -0,0 +1,97 @@
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
+62
View File
@@ -0,0 +1,62 @@
# Dependencies
node_modules/
*/node_modules/
# Build outputs
dist/
*/dist/
out/
*/out/
.next/
*/.next/
# Environment files
.env
.env.local
.env.*.local
backend/.env
frontend/.env.local
# Database
*.db
*.db-journal
# Logs
logs/
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
# Runtime data
pids/
*.pid
*.seed
# Coverage
coverage/
*.lcov
# IDE
.vscode/
.idea/
*.swp
*.swo
*~
# OS
.DS_Store
Thumbs.db
# Certificates (keep template, ignore actual certs)
backend/cert/key/*.pem
backend/cert/key/*.p12
backend/cert/key/*.txt
# Temp files
*.tmp
*.temp
# Playwright E2E test output
test-results/
playwright-report/
+112
View File
@@ -0,0 +1,112 @@
# AGENTS.md — 项目知识库(AI 专用)
> 每次任务前先读此文件。维护项目关键上下文,避免重复探索。
## 项目概览
宇之然 AI 学习与实践平台。前端 Next.js + shadcn/ui,后端 NestJS + PostgreSQL。
| 项目 | 值 |
|------|-----|
| 品牌 | 宇之然(北京宇之然科技中心) |
| 域名 | 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`
## 关键架构决策
| 决策 | 方案 | 原因 |
|------|------|------|
| 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,返回阶段任务完成情况) |
## 关键上下文(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
# 前端开发
cd frontend && npm run dev
# 后端开发
cd backend && npm run start:dev
# 前端构建
cd frontend && npm run build
# 后端测试
cd backend && npm test
npm run test:e2e
```
---
> 此文件仅在架构/约定变更时更新。进度追踪见 `docs/progress.md`。
+98
View File
@@ -0,0 +1,98 @@
# 宇之然 AI (YuZhiRan AI)
AI 学习与实践平台 — 让每个人都能用好 AI
| 信息 | 内容 |
|------|------|
| 品牌 | 宇之然(北京宇之然科技中心) |
| 官网 | https://yuzhiran.com |
| 备域名 | https://yuzhiran.com.cn |
| ICP 备案 | ✅ 已完成 |
## 项目结构
```
ai-learning-platform/
├── README.md # 项目说明
├── docs/ # 项目文档
│ ├── 产品规划书.md # 完整产品规划(含商业模式、可行性分析)
│ ├── 技术架构设计.md # 技术选型与系统架构
│ └── 合规与运营方案.md # ICP合规、数据合规、运营策略
├── frontend/ # 官网前端(Next.js + Tailwind
├── mobile/ # 移动端 App/小程序
├── backend/ # 后端 APINestJS + Prisma + MySQL
│ ├── src/modules/payment/ # 微信支付模块(已完善)
│ ├── src/modules/ai/ # AI模型接入(支持OpenAI兼容接口)
│ └── prisma/ # 数据模型定义
└── scripts/ # 工具脚本
└── setup.sh # 项目初始化脚本
```
## 技术栈
### 后端
- **框架**: NestJS (Node.js)
- **ORM**: Prisma
- **数据库**: MySQL
- **支付**: 微信支付 V3 SDK
- **AI接口**: OpenAI兼容接口、DeepSeek、通义千问
### 前端
- **框架**: Next.js 14
- **样式**: Tailwind CSS
- **状态管理**: React Hooks
## 快速开始
### 1. 初始化项目
```bash
cd ai-learning-platform
./scripts/setup.sh
```
### 2. 手动配置
#### 后端配置
编辑 `backend/.env` 文件,配置:
- 微信支付参数(已从 `/www/wwwroot/sharefile/note.txt` 获取)
- AI模型API密钥
- 数据库密码
#### 前端配置
编辑 `frontend/.env.local` 文件,配置API地址。
### 3. 启动开发环境
```bash
# 启动后端(默认端口3000
cd backend && npm run dev
# 启动前端(默认端口3001
cd frontend && npm run dev
```
### 4. 访问
- 前端: http://localhost:3001
- 后端API文档: http://localhost:3000/api
## 核心功能
- ✅ 用户系统(注册、登录、会员管理)
- ✅ 微信支付集成(JSAPI/NATIVE/MWEB
- ✅ 会员订阅管理(月卡/年卡自动续期)
- ✅ AI模型接入(支持美团longcat等OpenAI兼容接口)
- ✅ 课程系统
- ✅ 提示词库
- ✅ AI实操沙箱
- ✅ 社区功能
## 产品定位
面向大众化分领域用户的 AI 学习与实践平台,涵盖:
- AI 基础知识与通识教育
- 提示词工程教学与模板库
- 智能体(Agent)使用教程
- 大模型介绍与选型指南
- AI 实操沙箱体验
+10
View File
@@ -0,0 +1,10 @@
node_modules
dist
.next
.env
.env.local
*.log
.git
.gitignore
.DS_Store
coverage
+32
View File
@@ -0,0 +1,32 @@
# 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=
+36
View File
@@ -0,0 +1,36 @@
# Build stage
FROM node:20-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci --only=production && cp -R node_modules /prod_modules
RUN npm ci
COPY tsconfig*.json ./
COPY src/ ./src/
RUN npm run build && \
rm -rf node_modules && \
mv /prod_modules node_modules
# Production stage
FROM node:20-alpine
WORKDIR /app
RUN apk add --no-cache tini
COPY --from=builder /app/dist ./dist
COPY --from=builder /app/node_modules ./node_modules
COPY --from=builder /app/package*.json ./
COPY prisma/ ./prisma/
RUN npx prisma generate
EXPOSE 4000
USER node
ENTRYPOINT ["/sbin/tini", "--"]
CMD ["node", "dist/main.js"]
+798
View File
@@ -0,0 +1,798 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>宇之然 AI - 管理后台</title>
<script src="https://cdn.tailwindcss.com"></script>
<script>tailwind.config={theme:{extend:{colors:{brand:{50:'#f0f7ff',100:'#e0effe',200:'#bae0fd',300:'#7cc8fb',400:'#36aaf5',500:'#0c8ee7',600:'#0070c4',700:'#015a9f',800:'#064c83',900:'#0b406d'}}}}}}</script>
</head>
<body class="bg-gray-50 min-h-screen">
<div id="loginPage" class="min-h-screen flex items-center justify-center">
<div class="bg-white p-8 rounded-2xl shadow-sm border w-full max-w-sm">
<div class="text-center mb-8">
<h1 class="text-2xl font-bold text-brand-600">宇之然 AI</h1>
<p class="text-gray-500 text-sm mt-1">管理后台</p>
</div>
<div class="space-y-4">
<input id="username" type="text" placeholder="管理员账号" class="w-full px-4 py-2.5 border rounded-lg text-sm focus:outline-none focus:border-brand-400" autocomplete="username">
<input id="password" type="password" placeholder="密码" class="w-full px-4 py-2.5 border rounded-lg text-sm focus:outline-none focus:border-brand-400" autocomplete="current-password">
<button onclick="login()" class="w-full py-2.5 bg-brand-600 text-white text-sm font-medium rounded-lg hover:bg-brand-700">登录</button>
<p id="loginError" class="text-red-500 text-sm text-center hidden"></p>
</div>
</div>
</div>
<div id="adminApp" class="hidden">
<nav class="bg-white border-b px-6 py-3 flex items-center justify-between sticky top-0 z-50">
<div class="flex items-center gap-3">
<h1 class="text-lg font-bold text-brand-600">宇之然 AI 管理后台</h1>
</div>
<div class="flex items-center gap-4">
<span id="adminName" class="text-sm text-gray-500"></span>
<button onclick="logout()" class="text-sm text-gray-400 hover:text-red-500">退出</button>
</div>
</nav>
<div class="flex">
<aside class="w-56 bg-white border-r min-h-[calc(100vh-4rem)] p-4 flex-shrink-0 overflow-y-auto">
<nav class="space-y-1">
<a href="#" onclick="showPage('dashboard')" class="nav-item block px-3 py-2 rounded-lg text-sm bg-brand-50 text-brand-600 font-medium" data-page="dashboard">📊 数据看板</a>
<a href="#" onclick="showPage('categories')" class="nav-item block px-3 py-2 rounded-lg text-sm text-gray-600 hover:bg-gray-50" data-page="categories">📂 分类管理</a>
<a href="#" onclick="showPage('users')" class="nav-item block px-3 py-2 rounded-lg text-sm text-gray-600 hover:bg-gray-50" data-page="users">👥 用户管理</a>
<a href="#" onclick="showPage('courses')" class="nav-item block px-3 py-2 rounded-lg text-sm text-gray-600 hover:bg-gray-50" data-page="courses">📚 课程管理</a>
<a href="#" onclick="showPage('contents')" class="nav-item block px-3 py-2 rounded-lg text-sm text-gray-600 hover:bg-gray-50" data-page="contents">📝 内容管理</a>
<a href="#" onclick="showPage('prompts')" class="nav-item block px-3 py-2 rounded-lg text-sm text-gray-600 hover:bg-gray-50" data-page="prompts">💡 提示词管理</a>
<a href="#" onclick="showPage('tools')" class="nav-item block px-3 py-2 rounded-lg text-sm text-gray-600 hover:bg-gray-50" data-page="tools">🔧 工具管理</a>
<a href="#" onclick="showPage('orders')" class="nav-item block px-3 py-2 rounded-lg text-sm text-gray-600 hover:bg-gray-50" data-page="orders">💰 订单管理</a>
</nav>
</aside>
<main class="flex-1 p-6 overflow-auto">
<div id="toast" class="fixed top-4 right-4 z-50 hidden"></div>
<div id="page-dashboard" class="page-content">
<h2 class="text-xl font-bold mb-6">数据看板</h2>
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-5 gap-4 mb-8" id="statsGrid">
<div class="bg-white p-5 rounded-xl border"><p class="text-sm text-gray-400">用户总数</p><p class="text-2xl font-bold" id="stat-userCount">-</p></div>
<div class="bg-white p-5 rounded-xl border"><p class="text-sm text-gray-400">课程总数</p><p class="text-2xl font-bold" id="stat-courseCount">-</p></div>
<div class="bg-white p-5 rounded-xl border"><p class="text-sm text-gray-400">内容总数</p><p class="text-2xl font-bold" id="stat-contentCount">-</p></div>
<div class="bg-white p-5 rounded-xl border"><p class="text-sm text-gray-400">提示词数</p><p class="text-2xl font-bold" id="stat-promptCount">-</p></div>
<div class="bg-white p-5 rounded-xl border"><p class="text-sm text-gray-400">订单数</p><p class="text-2xl font-bold" id="stat-orderCount">-</p></div>
</div>
</div>
<div id="page-categories" class="page-content hidden">
<div class="flex items-center justify-between mb-6">
<h2 class="text-xl font-bold">分类管理</h2>
<button onclick="showCategoryForm()" class="px-4 py-2 bg-brand-600 text-white text-sm rounded-lg hover:bg-brand-700">+ 新建分类</button>
</div>
<div id="categoryForm" class="hidden mb-6 bg-white p-6 rounded-xl border max-w-lg">
<h3 class="text-lg font-semibold mb-4" id="categoryFormTitle">新建分类</h3>
<div class="space-y-3">
<input id="caf-name" placeholder="分类名称" class="w-full px-3 py-2 border rounded-lg text-sm">
<input id="caf-slug" placeholder="标识 (slug)" class="w-full px-3 py-2 border rounded-lg text-sm">
<input id="caf-desc" placeholder="描述" class="w-full px-3 py-2 border rounded-lg text-sm">
<input id="caf-sort" type="number" placeholder="排序 (数字越小越靠前)" class="w-full px-3 py-2 border rounded-lg text-sm">
<div class="flex gap-2">
<button onclick="saveCategory()" class="px-4 py-2 bg-brand-600 text-white text-sm rounded-lg">保存</button>
<button onclick="cancelCategoryForm()" class="px-4 py-2 border text-sm rounded-lg">取消</button>
</div>
</div>
</div>
<div class="bg-white rounded-xl border overflow-hidden">
<table class="w-full text-sm">
<thead class="bg-gray-50 text-gray-500">
<tr><th class="text-left px-4 py-3 font-medium">ID</th><th class="text-left px-4 py-3 font-medium">名称</th><th class="text-left px-4 py-3 font-medium">标识</th><th class="text-left px-4 py-3 font-medium">排序</th><th class="text-left px-4 py-3 font-medium">关联内容</th><th class="text-left px-4 py-3 font-medium">操作</th></tr>
</thead>
<tbody id="categoriesTable" class="divide-y"></tbody>
</table>
</div>
</div>
<div id="page-users" class="page-content hidden">
<h2 class="text-xl font-bold mb-6">用户管理</h2>
<div class="bg-white rounded-xl border overflow-hidden">
<table class="w-full text-sm">
<thead class="bg-gray-50 text-gray-500">
<tr><th class="text-left px-4 py-3 font-medium">ID</th><th class="text-left px-4 py-3 font-medium">昵称</th><th class="text-left px-4 py-3 font-medium">手机号</th><th class="text-left px-4 py-3 font-medium">邮箱</th><th class="text-left px-4 py-3 font-medium">会员</th><th class="text-left px-4 py-3 font-medium">注册时间</th></tr>
</thead>
<tbody id="usersTable" class="divide-y"></tbody>
</table>
</div>
</div>
<div id="page-courses" class="page-content hidden">
<div class="flex items-center justify-between mb-6">
<h2 class="text-xl font-bold">课程管理</h2>
<button onclick="showCourseForm()" class="px-4 py-2 bg-brand-600 text-white text-sm rounded-lg hover:bg-brand-700">+ 新建课程</button>
</div>
<div class="bg-white rounded-xl border overflow-hidden">
<table class="w-full text-sm">
<thead class="bg-gray-50 text-gray-500">
<tr><th class="text-left px-4 py-3 font-medium">ID</th><th class="text-left px-4 py-3 font-medium">标题</th><th class="text-left px-4 py-3 font-medium">分类</th><th class="text-left px-4 py-3 font-medium">价格</th><th class="text-left px-4 py-3 font-medium">课时</th><th class="text-left px-4 py-3 font-medium">状态</th><th class="text-left px-4 py-3 font-medium">操作</th></tr>
</thead>
<tbody id="coursesTable" class="divide-y"></tbody>
</table>
</div>
<div id="courseForm" class="hidden mt-6 bg-white p-6 rounded-xl border max-w-4xl">
<h3 class="text-lg font-semibold mb-4" id="courseFormTitle">新建课程</h3>
<div class="space-y-3">
<input id="cf-title" placeholder="课程标题" class="w-full px-3 py-2 border rounded-lg text-sm">
<textarea id="cf-desc" placeholder="课程描述" class="w-full px-3 py-2 border rounded-lg text-sm" rows="2"></textarea>
<div class="flex gap-3">
<select id="cf-category" class="flex-1 px-3 py-2 border rounded-lg text-sm"></select>
<input id="cf-price" type="number" placeholder="价格(元)" class="w-32 px-3 py-2 border rounded-lg text-sm">
</div>
<div class="flex gap-3 items-center">
<label class="flex items-center gap-2 text-sm"><input type="checkbox" id="cf-isFree" checked> 免费</label>
<select id="cf-status" class="px-3 py-2 border rounded-lg text-sm">
<option value="DRAFT">草稿</option><option value="PUBLISHED">已发布</option>
</select>
<input id="cf-sort" type="number" placeholder="排序" class="w-20 px-3 py-2 border rounded-lg text-sm">
</div>
</div>
<div id="chaptersSection" class="mt-6 hidden">
<h4 class="text-md font-semibold text-gray-900 mb-3 flex items-center justify-between">
<span>章节管理</span>
<button onclick="addChapter()" class="text-xs px-3 py-1 bg-gray-100 rounded-lg hover:bg-gray-200">+ 添加章节</button>
</h4>
<div id="chaptersList" class="space-y-3"></div>
</div>
<div class="flex gap-2 mt-4">
<button onclick="saveCourse()" class="px-6 py-2 bg-brand-600 text-white text-sm rounded-lg hover:bg-brand-700">保存课程</button>
<button onclick="cancelCourseForm()" class="px-4 py-2 border text-sm rounded-lg">取消</button>
</div>
</div>
</div>
<div id="page-contents" class="page-content hidden">
<div class="flex items-center justify-between mb-6">
<h2 class="text-xl font-bold">内容管理</h2>
<button onclick="showContentForm()" class="px-4 py-2 bg-brand-600 text-white text-sm rounded-lg hover:bg-brand-700">+ 新建内容</button>
</div>
<div class="bg-white rounded-xl border overflow-hidden">
<table class="w-full text-sm">
<thead class="bg-gray-50 text-gray-500">
<tr><th class="text-left px-4 py-3 font-medium">ID</th><th class="text-left px-4 py-3 font-medium">标题</th><th class="text-left px-4 py-3 font-medium">类型</th><th class="text-left px-4 py-3 font-medium">浏览量</th><th class="text-left px-4 py-3 font-medium">状态</th><th class="text-left px-4 py-3 font-medium">操作</th></tr>
</thead>
<tbody id="contentsTable" class="divide-y"></tbody>
</table>
</div>
<div id="contentForm" class="hidden mt-6 bg-white p-6 rounded-xl border max-w-2xl">
<h3 class="text-lg font-semibold mb-4" id="contentFormTitle">新建内容</h3>
<div class="space-y-3">
<input id="cof-title" placeholder="内容标题" class="w-full px-3 py-2 border rounded-lg text-sm">
<textarea id="cof-summary" placeholder="摘要" class="w-full px-3 py-2 border rounded-lg text-sm" rows="2"></textarea>
<textarea id="cof-content" placeholder="内容 (支持 Markdown)" class="w-full px-3 py-2 border rounded-lg text-sm" rows="8"></textarea>
<div class="flex gap-3">
<select id="cof-type" class="px-3 py-2 border rounded-lg text-sm">
<option value="article">文章</option><option value="tutorial">教程</option><option value="news">资讯</option>
</select>
<select id="cof-status" class="px-3 py-2 border rounded-lg text-sm">
<option value="DRAFT">草稿</option><option value="PUBLISHED">已发布</option>
</select>
</div>
<div class="flex gap-2">
<button onclick="saveContent()" class="px-4 py-2 bg-brand-600 text-white text-sm rounded-lg">保存</button>
<button onclick="cancelContentForm()" class="px-4 py-2 border text-sm rounded-lg">取消</button>
</div>
</div>
</div>
</div>
<div id="page-prompts" class="page-content hidden">
<div class="flex items-center justify-between mb-6">
<h2 class="text-xl font-bold">提示词管理</h2>
<button onclick="showPromptForm()" class="px-4 py-2 bg-brand-600 text-white text-sm rounded-lg hover:bg-brand-700">+ 新建提示词</button>
</div>
<div class="bg-white rounded-xl border overflow-hidden">
<table class="w-full text-sm">
<thead class="bg-gray-50 text-gray-500">
<tr><th class="text-left px-4 py-3 font-medium">ID</th><th class="text-left px-4 py-3 font-medium">标题</th><th class="text-left px-4 py-3 font-medium">标签</th><th class="text-left px-4 py-3 font-medium">模型</th><th class="text-left px-4 py-3 font-medium">状态</th><th class="text-left px-4 py-3 font-medium">操作</th></tr>
</thead>
<tbody id="promptsTable" class="divide-y"></tbody>
</table>
</div>
<div id="promptForm" class="hidden mt-6 bg-white p-6 rounded-xl border max-w-2xl">
<h3 class="text-lg font-semibold mb-4" id="promptFormTitle">新建提示词</h3>
<div class="space-y-3">
<input id="pf-title" placeholder="提示词标题" class="w-full px-3 py-2 border rounded-lg text-sm">
<textarea id="pf-content" placeholder="提示词内容" class="w-full px-3 py-2 border rounded-lg text-sm" rows="6"></textarea>
<input id="pf-tags" placeholder="标签(逗号分隔)" class="w-full px-3 py-2 border rounded-lg text-sm">
<div class="flex gap-3">
<input id="pf-model" placeholder="适用模型" class="flex-1 px-3 py-2 border rounded-lg text-sm">
<select id="pf-status" class="px-3 py-2 border rounded-lg text-sm">
<option value="DRAFT">草稿</option><option value="PUBLISHED">已发布</option>
</select>
</div>
<div class="flex gap-2">
<button onclick="savePrompt()" class="px-4 py-2 bg-brand-600 text-white text-sm rounded-lg">保存</button>
<button onclick="cancelPromptForm()" class="px-4 py-2 border text-sm rounded-lg">取消</button>
</div>
</div>
</div>
</div>
<div id="page-tools" class="page-content hidden">
<div class="flex items-center justify-between mb-6">
<h2 class="text-xl font-bold">工具管理</h2>
<button onclick="showToolForm()" class="px-4 py-2 bg-brand-600 text-white text-sm rounded-lg hover:bg-brand-700">+ 新建工具</button>
</div>
<div class="bg-white rounded-xl border overflow-hidden">
<table class="w-full text-sm">
<thead class="bg-gray-50 text-gray-500">
<tr><th class="text-left px-4 py-3 font-medium">ID</th><th class="text-left px-4 py-3 font-medium">名称</th><th class="text-left px-4 py-3 font-medium">URL</th><th class="text-left px-4 py-3 font-medium">推荐</th><th class="text-left px-4 py-3 font-medium">状态</th><th class="text-left px-4 py-3 font-medium">操作</th></tr>
</thead>
<tbody id="toolsTable" class="divide-y"></tbody>
</table>
</div>
<div id="toolForm" class="hidden mt-6 bg-white p-6 rounded-xl border max-w-2xl">
<h3 class="text-lg font-semibold mb-4" id="toolFormTitle">新建工具</h3>
<div class="space-y-3">
<input id="tf-name" placeholder="工具名称" class="w-full px-3 py-2 border rounded-lg text-sm">
<input id="tf-url" placeholder="工具 URL" class="w-full px-3 py-2 border rounded-lg text-sm">
<textarea id="tf-desc" placeholder="描述" class="w-full px-3 py-2 border rounded-lg text-sm" rows="2"></textarea>
<input id="tf-tags" placeholder="标签(逗号分隔)" class="w-full px-3 py-2 border rounded-lg text-sm">
<div class="flex gap-3">
<label class="flex items-center gap-2 text-sm"><input type="checkbox" id="tf-featured"> 推荐</label>
<select id="tf-status" class="px-3 py-2 border rounded-lg text-sm">
<option value="DRAFT">草稿</option><option value="PUBLISHED">已发布</option>
</select>
</div>
<div class="flex gap-2">
<button onclick="saveTool()" class="px-4 py-2 bg-brand-600 text-white text-sm rounded-lg">保存</button>
<button onclick="cancelToolForm()" class="px-4 py-2 border text-sm rounded-lg">取消</button>
</div>
</div>
</div>
</div>
<div id="page-orders" class="page-content hidden">
<h2 class="text-xl font-bold mb-6">订单管理</h2>
<div class="bg-white rounded-xl border overflow-hidden">
<table class="w-full text-sm">
<thead class="bg-gray-50 text-gray-500">
<tr><th class="text-left px-4 py-3 font-medium">订单号</th><th class="text-left px-4 py-3 font-medium">用户</th><th class="text-left px-4 py-3 font-medium">金额</th><th class="text-left px-4 py-3 font-medium">套餐</th><th class="text-left px-4 py-3 font-medium">状态</th><th class="text-left px-4 py-3 font-medium">支付渠道</th><th class="text-left px-4 py-3 font-medium">时间</th></tr>
</thead>
<tbody id="ordersTable" class="divide-y"></tbody>
</table>
</div>
</div>
</main>
</div>
</div>
<script>
const API = 'http://localhost:4000/api/v1';
let editingId = null;
let chapterCounter = 0;
let categoriesCache = [];
function $(id) { return document.getElementById(id); }
function toast(msg, type = 'success') {
const el = $('toast');
el.className = `fixed top-4 right-4 z-50 px-4 py-3 rounded-lg text-sm font-medium shadow-lg transition-all ${
type === 'success' ? 'bg-green-600 text-white' : 'bg-red-600 text-white'
}`;
el.textContent = msg;
el.classList.remove('hidden');
setTimeout(() => el.classList.add('hidden'), 3000);
}
function loading(btn, isLoading) {
if (isLoading) { btn.disabled = true; btn._text = btn.textContent; btn.textContent = '处理中...'; }
else { btn.disabled = false; btn.textContent = btn._text || btn.textContent; }
}
async function api(url, opts = {}) {
const token = JSON.parse(localStorage.getItem('admin_user') || '{}').token;
const headers = { 'Content-Type': 'application/json' };
if (token) headers['Authorization'] = `Bearer ${token}`;
const res = await fetch(API + url, { headers, ...opts });
const data = await res.json();
if (!res.ok) throw new Error(data.message || '请求失败');
return data;
}
async function login() {
const username = $('username').value;
const password = $('password').value;
if (!username || !password) { $('loginError').classList.remove('hidden'); $('loginError').textContent = '请填写账号和密码'; return; }
try {
const data = await api('/admin/login', { method: 'POST', body: JSON.stringify({ username, password }) });
const tokenRes = await fetch(API + '/admin/dashboard', {
headers: { 'Authorization': 'Bearer ' + localStorage.getItem('_jwt') }
});
const loginRes = await fetch(API + '/auth/login', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ account: username, password })
});
const loginData = await loginRes.json();
data.token = loginData.accessToken || '';
if (!data.token) {
const adminStr = btoa(JSON.stringify({ id: data.id, username: data.username }));
data.token = 'admin_' + adminStr;
}
localStorage.setItem('admin_user', JSON.stringify(data));
$('loginPage').classList.add('hidden');
$('adminApp').classList.remove('hidden');
$('adminName').textContent = data.username;
showPage('dashboard');
} catch (e) {
$('loginError').classList.remove('hidden');
$('loginError').textContent = e.message || '无法连接服务器';
}
}
function logout() {
localStorage.removeItem('admin_user');
$('adminApp').classList.add('hidden');
$('loginPage').classList.remove('hidden');
}
const user = JSON.parse(localStorage.getItem('admin_user') || '{}');
if (user.id) {
$('loginPage').classList.add('hidden');
$('adminApp').classList.remove('hidden');
$('adminName').textContent = user.username;
showPage('dashboard');
}
function showPage(page) {
document.querySelectorAll('.page-content').forEach(el => el.classList.add('hidden'));
$(`page-${page}`).classList.remove('hidden');
document.querySelectorAll('.nav-item').forEach(el => {
el.classList.remove('bg-brand-50', 'text-brand-600', 'font-medium');
});
const navItem = document.querySelector(`[data-page="${page}"]`);
if (navItem) navItem.classList.add('bg-brand-50', 'text-brand-600', 'font-medium');
const loaders = {
dashboard: loadDashboard, categories: loadCategories, users: loadUsers,
courses: loadCourses, contents: loadContents, prompts: loadPrompts,
tools: loadTools, orders: loadOrders,
};
if (loaders[page]) loaders[page]();
}
async function loadDashboard() {
try {
const data = await api('/admin/dashboard');
if (data.stats) Object.keys(data.stats).forEach(k => {
const el = $(`stat-${k}`);
if (el) el.textContent = data.stats[k].toLocaleString();
});
} catch (e) { toast('加载数据看板失败: ' + e.message, 'error'); }
}
async function loadCategories() {
try {
const data = await api('/categories');
$('categoriesTable').innerHTML = (Array.isArray(data) ? data : data.items || []).map(c =>
`<tr class="hover:bg-gray-50"><td class="px-4 py-3">${c.id}</td><td class="px-4 py-3 font-medium">${c.name}</td><td class="px-4 py-3 text-gray-400">${c.slug}</td><td class="px-4 py-3">${c.sortOrder}</td><td class="px-4 py-3 text-xs text-gray-400">课程 ${c._count?.courses||0} · 提示词 ${c._count?.prompts||0} · 工具 ${c._count?.tools||0}</td><td class="px-4 py-3"><button onclick="editCategory(${c.id})" class="text-brand-600 text-sm mr-2">编辑</button><button onclick="deleteCategory(${c.id})" class="text-red-500 text-sm">删除</button></td></tr>`
).join('') || '<tr><td colspan="6" class="px-4 py-8 text-center text-gray-400">暂无数据</td></tr>';
} catch (e) { toast('加载分类失败: ' + e.message, 'error'); }
}
function showCategoryForm() {
editingId = null;
$('categoryFormTitle').textContent = '新建分类';
['caf-name','caf-slug','caf-desc','caf-sort'].forEach(id => $(id).value = '');
$('categoryForm').classList.remove('hidden');
}
function cancelCategoryForm() { $('categoryForm').classList.add('hidden'); }
async function saveCategory() {
const body = {
name: $('caf-name').value, slug: $('caf-slug').value,
description: $('caf-desc').value, sortOrder: parseInt($('caf-sort').value) || 0,
};
if (!body.name || !body.slug) return toast('请填写名称和标识', 'error');
try {
const url = editingId ? `/categories/${editingId}` : '/categories';
await api(url, { method: editingId ? 'PUT' : 'POST', body: JSON.stringify(body) });
toast(editingId ? '分类已更新' : '分类已创建');
cancelCategoryForm(); loadCategories();
} catch (e) { toast(e.message, 'error'); }
}
async function editCategory(id) {
try {
const data = await api(`/categories/${id}`);
editingId = id;
$('categoryFormTitle').textContent = '编辑分类';
$('caf-name').value = data.name;
$('caf-slug').value = data.slug;
$('caf-desc').value = data.description || '';
$('caf-sort').value = data.sortOrder || 0;
$('categoryForm').classList.remove('hidden');
} catch (e) { toast(e.message, 'error'); }
}
async function deleteCategory(id) {
if (!confirm('确定删除此分类?关联内容不会被删除。')) return;
try {
await api(`/categories/${id}`, { method: 'DELETE' });
toast('分类已删除');
loadCategories();
} catch (e) { toast(e.message, 'error'); }
}
async function loadUsers() {
try {
const data = await api('/users');
$('usersTable').innerHTML = (data.items || []).map(u =>
`<tr class="hover:bg-gray-50"><td class="px-4 py-3">${u.id}</td><td class="px-4 py-3">${u.nickname || '-'}</td><td class="px-4 py-3">${u.phone || '-'}</td><td class="px-4 py-3">${u.email || '-'}</td><td class="px-4 py-3">${u.memberPlan === 'FREE' ? '免费' : u.memberPlan}</td><td class="px-4 py-3 text-gray-400">${new Date(u.createdAt).toLocaleDateString()}</td></tr>`
).join('') || '<tr><td colspan="6" class="px-4 py-8 text-center text-gray-400">暂无数据</td></tr>';
} catch (e) { toast('加载用户失败: ' + e.message, 'error'); }
}
async function loadCourses() {
try {
const data = await api('/courses');
$('coursesTable').innerHTML = (data.items || []).map(c => {
const lessonCount = c.chapters?.reduce((s, ch) => s + (ch.lessons?.length || 0), 0) || 0;
return `<tr class="hover:bg-gray-50"><td class="px-4 py-3">${c.id}</td><td class="px-4 py-3 font-medium">${c.title}</td><td class="px-4 py-3">${c.category?.name || '-'}</td><td class="px-4 py-3">${c.isFree ? '免费' : '¥' + c.price}</td><td class="px-4 py-3 text-xs text-gray-400">${c.chapters?.length||0} 章 / ${lessonCount} 课</td><td class="px-4 py-3"><span class="text-xs px-2 py-0.5 rounded ${c.status === 'PUBLISHED' ? 'bg-green-50 text-green-600' : 'bg-gray-50 text-gray-400'}">${c.status === 'PUBLISHED' ? '已发布' : '草稿'}</span></td><td class="px-4 py-3"><button onclick="editCourse(${c.id})" class="text-brand-600 text-sm mr-2">编辑</button><button onclick="deleteCourse(${c.id})" class="text-red-500 text-sm">删除</button></td></tr>`;
}).join('') || '<tr><td colspan="7" class="px-4 py-8 text-center text-gray-400">暂无数据</td></tr>';
} catch (e) { toast('加载课程失败: ' + e.message, 'error'); }
}
function showCourseForm() {
editingId = null;
$('courseFormTitle').textContent = '新建课程';
$('cf-title').value = ''; $('cf-desc').value = '';
$('cf-price').value = '0'; $('cf-isFree').checked = true;
$('cf-status').value = 'DRAFT'; $('cf-sort').value = '0';
$('chaptersSection').classList.add('hidden');
$('chaptersList').innerHTML = '';
loadCategorySelect('cf-category');
$('courseForm').classList.remove('hidden');
}
function cancelCourseForm() {
$('courseForm').classList.add('hidden');
$('chaptersSection').classList.add('hidden');
}
async function loadCategorySelect(id) {
try {
const data = await api('/categories');
const cats = Array.isArray(data) ? data : data.items || [];
categoriesCache = cats;
$(id).innerHTML = '<option value="">无分类</option>' + cats.map(c =>
`<option value="${c.id}">${c.name}</option>`
).join('');
} catch (e) {
$(id).innerHTML = '<option value="">无分类</option>';
}
}
function addChapter(data) {
const idx = chapterCounter++;
const div = document.createElement('div');
div.className = 'chapter-block bg-gray-50 rounded-lg p-3 border';
div.dataset.idx = idx;
div.innerHTML = `
<div class="flex items-center gap-2 mb-2">
<span class="text-xs text-gray-400 font-medium chapter-num">第 ${$('#chaptersList').children.length + 1} 章</span>
<input class="chapter-title flex-1 px-2 py-1 border rounded text-sm" placeholder="章节标题" value="${data?.title || ''}">
<input class="chapter-sort w-16 px-2 py-1 border rounded text-sm" type="number" placeholder="排序" value="${data?.sortOrder || 0}">
<button onclick="removeChapter(this)" class="text-red-400 hover:text-red-600 text-sm px-1">✕</button>
</div>
<div class="lessons-list space-y-1 ml-2"></div>
<button onclick="addLesson(this)" class="text-xs text-brand-600 hover:text-brand-700 mt-1 ml-2">+ 添加课时</button>
`;
if (data?.lessons) {
data.lessons.forEach(l => {
const lessonDiv = createLessonEl(l);
div.querySelector('.lessons-list').appendChild(lessonDiv);
});
}
$('chaptersList').appendChild(div);
updateChapterNumbers();
}
function createLessonEl(data) {
const div = document.createElement('div');
div.className = 'lesson-item flex items-center gap-2 py-1';
div.innerHTML = `
<span class="text-xs text-gray-400 w-6">${$('#chaptersList').querySelectorAll('.lesson-item').length + 1}.</span>
<input class="lesson-title flex-1 px-2 py-1 border rounded text-xs" placeholder="课时标题" value="${data?.title || ''}">
<input class="lesson-sort w-14 px-2 py-1 border rounded text-xs" type="number" placeholder="排序" value="${data?.sortOrder || 0}">
<select class="lesson-status px-2 py-1 border rounded text-xs">
<option value="PUBLISHED" ${data?.status === 'PUBLISHED' ? 'selected' : ''}>已发布</option>
<option value="DRAFT" ${data?.status === 'DRAFT' ? 'selected' : ''}>草稿</option>
</select>
<button onclick="this.parentElement.remove()" class="text-red-300 hover:text-red-500 text-xs px-1">✕</button>
`;
return div;
}
function addLesson(btn) {
const lessonsList = btn.previousElementSibling;
const el = createLessonEl(null);
lessonsList.appendChild(el);
updateLessonNumbers();
}
function removeChapter(btn) {
btn.closest('.chapter-block').remove();
updateChapterNumbers();
}
function updateChapterNumbers() {
$('chaptersList').querySelectorAll('.chapter-block').forEach((el, i) => {
el.querySelector('.chapter-num').textContent = `${i + 1}`;
});
}
function updateLessonNumbers() {
$('chaptersList').querySelectorAll('.lesson-item').forEach((el, i) => {
el.querySelector('span').textContent = `${i + 1}.`;
});
}
function collectChaptersData() {
const chapters = [];
$('chaptersList').querySelectorAll('.chapter-block').forEach(block => {
const lessons = [];
block.querySelectorAll('.lesson-item').forEach(item => {
lessons.push({
title: item.querySelector('.lesson-title').value,
sortOrder: parseInt(item.querySelector('.lesson-sort').value) || 0,
status: item.querySelector('.lesson-status').value,
});
});
chapters.push({
title: block.querySelector('.chapter-title').value,
sortOrder: parseInt(block.querySelector('.chapter-sort').value) || 0,
lessons,
});
});
return chapters;
}
async function saveCourse() {
const title = $('cf-title').value;
if (!title) return toast('请输入课程标题', 'error');
const chapters = collectChaptersData();
const body = {
title,
description: $('cf-desc').value,
price: parseFloat($('cf-price').value) || 0,
isFree: $('cf-isFree').checked,
status: $('cf-status').value,
sortOrder: parseInt($('cf-sort').value) || 0,
chapters,
};
if ($('cf-category').value) body.categoryId = parseInt($('cf-category').value);
try {
if (editingId) {
await api(`/courses/${editingId}`, { method: 'PUT', body: JSON.stringify({ title: body.title, description: body.description, price: body.price, isFree: body.isFree, status: body.status, sortOrder: body.sortOrder, categoryId: body.categoryId }) });
if (chapters.length > 0) {
await api(`/admin/courses/${editingId}/chapters`, { method: 'PUT', body: JSON.stringify({ chapters }) });
}
toast('课程已更新');
} else {
const course = await api('/courses', { method: 'POST', body: JSON.stringify(body) });
if (chapters.length > 0 && course.id) {
await api(`/admin/courses/${course.id}/chapters`, { method: 'PUT', body: JSON.stringify({ chapters }) });
}
toast('课程已创建');
}
cancelCourseForm(); loadCourses();
} catch (e) { toast(e.message, 'error'); }
}
async function editCourse(id) {
try {
const data = await api(`/courses/${id}`);
editingId = id;
$('courseFormTitle').textContent = '编辑课程';
$('cf-title').value = data.title;
$('cf-desc').value = data.description || '';
$('cf-price').value = data.price;
$('cf-isFree').checked = data.isFree;
$('cf-status').value = data.status;
$('cf-sort').value = data.sortOrder || 0;
await loadCategorySelect('cf-category');
if (data.categoryId) $('cf-category').value = data.categoryId;
$('chaptersList').innerHTML = '';
chapterCounter = 0;
if (data.chapters && data.chapters.length > 0) {
$('chaptersSection').classList.remove('hidden');
data.chapters.forEach(ch => addChapter(ch));
} else {
$('chaptersSection').classList.remove('hidden');
}
$('courseForm').classList.remove('hidden');
} catch (e) { toast(e.message, 'error'); }
}
async function deleteCourse(id) {
if (!confirm('确定删除此课程?')) return;
try {
await api(`/courses/${id}`, { method: 'DELETE' });
toast('课程已删除');
loadCourses();
} catch (e) { toast(e.message, 'error'); }
}
async function loadContents() {
try {
const data = await api('/contents');
$('contentsTable').innerHTML = (data.items || []).map(c =>
`<tr class="hover:bg-gray-50"><td class="px-4 py-3">${c.id}</td><td class="px-4 py-3">${c.title}</td><td class="px-4 py-3">${c.contentType}</td><td class="px-4 py-3">${c.viewCount}</td><td class="px-4 py-3"><span class="text-xs px-2 py-0.5 rounded ${c.status === 'PUBLISHED' ? 'bg-green-50 text-green-600' : 'bg-gray-50 text-gray-400'}">${c.status === 'PUBLISHED' ? '已发布' : '草稿'}</span></td><td class="px-4 py-3"><button onclick="editContent(${c.id})" class="text-brand-600 text-sm mr-2">编辑</button><button onclick="deleteContent(${c.id})" class="text-red-500 text-sm">删除</button></td></tr>`
).join('') || '<tr><td colspan="6" class="px-4 py-8 text-center text-gray-400">暂无数据</td></tr>';
} catch (e) { toast('加载内容失败: ' + e.message, 'error'); }
}
function showContentForm() {
editingId = null;
$('contentFormTitle').textContent = '新建内容';
['cof-title','cof-summary','cof-content'].forEach(id => $(id).value = '');
$('cof-type').value = 'article'; $('cof-status').value = 'DRAFT';
$('contentForm').classList.remove('hidden');
}
function cancelContentForm() { $('contentForm').classList.add('hidden'); }
async function saveContent() {
const body = { title: $('cof-title').value, summary: $('cof-summary').value, content: $('cof-content').value, contentType: $('cof-type').value, status: $('cof-status').value };
if (!body.title) return toast('请输入标题', 'error');
try {
await api(editingId ? `/contents/${editingId}` : '/contents', {
method: editingId ? 'PUT' : 'POST', body: JSON.stringify(body),
});
toast(editingId ? '内容已更新' : '内容已创建');
cancelContentForm(); loadContents();
} catch (e) { toast(e.message, 'error'); }
}
async function editContent(id) {
try {
const data = await api(`/contents/${id}`);
editingId = id;
$('contentFormTitle').textContent = '编辑内容';
$('cof-title').value = data.title;
$('cof-summary').value = data.summary || '';
$('cof-content').value = data.content || '';
$('cof-type').value = data.contentType;
$('cof-status').value = data.status;
$('contentForm').classList.remove('hidden');
} catch (e) { toast(e.message, 'error'); }
}
async function deleteContent(id) {
if (!confirm('确定删除?')) return;
try {
await api(`/contents/${id}`, { method: 'DELETE' });
toast('内容已删除');
loadContents();
} catch (e) { toast(e.message, 'error'); }
}
async function loadPrompts() {
try {
const data = await api('/prompts');
$('promptsTable').innerHTML = (data.items || []).map(p =>
`<tr class="hover:bg-gray-50"><td class="px-4 py-3">${p.id}</td><td class="px-4 py-3">${p.title}</td><td class="px-4 py-3">${p.tags || '-'}</td><td class="px-4 py-3">${p.model || '-'}</td><td class="px-4 py-3"><span class="text-xs px-2 py-0.5 rounded ${p.status === 'PUBLISHED' ? 'bg-green-50 text-green-600' : 'bg-gray-50 text-gray-400'}">${p.status === 'PUBLISHED' ? '已发布' : '草稿'}</span></td><td class="px-4 py-3"><button onclick="editPrompt(${p.id})" class="text-brand-600 text-sm mr-2">编辑</button><button onclick="deletePrompt(${p.id})" class="text-red-500 text-sm">删除</button></td></tr>`
).join('') || '<tr><td colspan="6" class="px-4 py-8 text-center text-gray-400">暂无数据</td></tr>';
} catch (e) { toast('加载提示词失败: ' + e.message, 'error'); }
}
function showPromptForm() {
editingId = null;
$('promptFormTitle').textContent = '新建提示词';
['pf-title','pf-content','pf-tags','pf-model'].forEach(id => $(id).value = '');
$('pf-status').value = 'DRAFT';
$('promptForm').classList.remove('hidden');
}
function cancelPromptForm() { $('promptForm').classList.add('hidden'); }
async function savePrompt() {
const body = { title: $('pf-title').value, content: $('pf-content').value, tags: $('pf-tags').value, model: $('pf-model').value, status: $('pf-status').value };
if (!body.title || !body.content) return toast('请输入标题和内容', 'error');
try {
await api(editingId ? `/prompts/${editingId}` : '/prompts', {
method: editingId ? 'PUT' : 'POST', body: JSON.stringify(body),
});
toast(editingId ? '提示词已更新' : '提示词已创建');
cancelPromptForm(); loadPrompts();
} catch (e) { toast(e.message, 'error'); }
}
async function editPrompt(id) {
try {
const data = await api(`/prompts/${id}`);
editingId = id;
$('promptFormTitle').textContent = '编辑提示词';
$('pf-title').value = data.title;
$('pf-content').value = data.content;
$('pf-tags').value = data.tags || '';
$('pf-model').value = data.model || '';
$('pf-status').value = data.status;
$('promptForm').classList.remove('hidden');
} catch (e) { toast(e.message, 'error'); }
}
async function deletePrompt(id) {
if (!confirm('确定删除?')) return;
try {
await api(`/prompts/${id}`, { method: 'DELETE' });
toast('提示词已删除');
loadPrompts();
} catch (e) { toast(e.message, 'error'); }
}
async function loadTools() {
try {
const data = await api('/tools');
$('toolsTable').innerHTML = (data.items || []).map(t =>
`<tr class="hover:bg-gray-50"><td class="px-4 py-3">${t.id}</td><td class="px-4 py-3 font-medium">${t.name}</td><td class="px-4 py-3"><a href="${t.url}" target="_blank" class="text-brand-600 text-xs">${(t.url||'').slice(0, 30)}...</a></td><td class="px-4 py-3">${t.isFeatured ? '⭐' : '-'}</td><td class="px-4 py-3"><span class="text-xs px-2 py-0.5 rounded ${t.status === 'PUBLISHED' ? 'bg-green-50 text-green-600' : 'bg-gray-50 text-gray-400'}">${t.status === 'PUBLISHED' ? '已发布' : '草稿'}</span></td><td class="px-4 py-3"><button onclick="editTool(${t.id})" class="text-brand-600 text-sm mr-2">编辑</button><button onclick="deleteTool(${t.id})" class="text-red-500 text-sm">删除</button></td></tr>`
).join('') || '<tr><td colspan="6" class="px-4 py-8 text-center text-gray-400">暂无数据</td></tr>';
} catch (e) { toast('加载工具失败: ' + e.message, 'error'); }
}
function showToolForm() {
editingId = null;
$('toolFormTitle').textContent = '新建工具';
['tf-name','tf-url','tf-desc','tf-tags'].forEach(id => $(id).value = '');
$('tf-featured').checked = false;
$('tf-status').value = 'DRAFT';
$('toolForm').classList.remove('hidden');
}
function cancelToolForm() { $('toolForm').classList.add('hidden'); }
async function saveTool() {
const body = { name: $('tf-name').value, url: $('tf-url').value, description: $('tf-desc').value, tags: $('tf-tags').value, isFeatured: $('tf-featured').checked, status: $('tf-status').value };
if (!body.name || !body.url) return toast('请输入名称和URL', 'error');
try {
await api(editingId ? `/tools/${editingId}` : '/tools', {
method: editingId ? 'PUT' : 'POST', body: JSON.stringify(body),
});
toast(editingId ? '工具已更新' : '工具已创建');
cancelToolForm(); loadTools();
} catch (e) { toast(e.message, 'error'); }
}
async function editTool(id) {
try {
const data = await api(`/tools/${id}`);
editingId = id;
$('toolFormTitle').textContent = '编辑工具';
$('tf-name').value = data.name;
$('tf-url').value = data.url;
$('tf-desc').value = data.description || '';
$('tf-tags').value = data.tags || '';
$('tf-featured').checked = data.isFeatured;
$('tf-status').value = data.status;
$('toolForm').classList.remove('hidden');
} catch (e) { toast(e.message, 'error'); }
}
async function deleteTool(id) {
if (!confirm('确定删除?')) return;
try {
await api(`/tools/${id}`, { method: 'DELETE' });
toast('工具已删除');
loadTools();
} catch (e) { toast(e.message, 'error'); }
}
async function loadOrders() {
try {
const data = await api('/admin/orders');
$('ordersTable').innerHTML = (data.items || []).map(o =>
`<tr class="hover:bg-gray-50"><td class="px-4 py-3 font-mono text-xs">${o.orderNo}</td><td class="px-4 py-3">${o.user?.nickname || o.user?.phone || '用户#' + o.userId}</td><td class="px-4 py-3">¥${o.amount.toFixed(2)}</td><td class="px-4 py-3">${o.planType}</td><td class="px-4 py-3"><span class="text-xs px-2 py-0.5 rounded ${
o.status === 'PAID' ? 'bg-green-50 text-green-600' :
o.status === 'REFUNDED' ? 'bg-red-50 text-red-600' :
'bg-yellow-50 text-yellow-600'
}">${o.status === 'PAID' ? '已支付' : o.status === 'REFUNDED' ? '已退款' : o.status === 'CANCELLED' ? '已取消' : '待支付'}</span></td><td class="px-4 py-3 text-xs text-gray-400">${o.payChannel || '-'}</td><td class="px-4 py-3 text-xs text-gray-400">${new Date(o.createdAt).toLocaleString()}</td></tr>`
).join('') || '<tr><td colspan="7" class="px-4 py-8 text-center text-gray-400">暂无订单数据</td></tr>';
} catch (e) { toast('加载订单失败: ' + e.message, 'error'); }
}
</script>
</body>
</html>
+12
View File
@@ -0,0 +1,12 @@
{
"moduleFileExtensions": ["js", "json", "ts"],
"rootDir": ".",
"testRegex": ".e2e-spec.ts$",
"transform": {
"^.+\\.(t|j)s$": "ts-jest"
},
"testEnvironment": "node",
"moduleNameMapper": {
"^@/(.*)$": "<rootDir>/src/$1"
}
}
+14
View File
@@ -0,0 +1,14 @@
{
"moduleFileExtensions": ["js", "json", "ts"],
"rootDir": ".",
"testRegex": ".*\\.spec\\.ts$",
"transform": {
"^.+\\.(t|j)s$": "ts-jest"
},
"collectCoverageFrom": ["**/*.(t|j)s"],
"coverageDirectory": "../coverage",
"testEnvironment": "node",
"moduleNameMapper": {
"^@/(.*)$": "<rootDir>/src/$1"
}
}
+8
View File
@@ -0,0 +1,8 @@
{
"$schema": "https://json.schemastore.org/nest-cli",
"collection": "@nestjs/schematics",
"sourceRoot": "src",
"compilerOptions": {
"deleteOutDir": true
}
}
+10441
View File
File diff suppressed because it is too large Load Diff
+73
View File
@@ -0,0 +1,73 @@
{
"name": "@yuzhiran/backend",
"version": "1.0.0",
"description": "宇之然 AI - 后端 API 服务",
"main": "dist/main.js",
"scripts": {
"build": "nest build",
"start": "nest start",
"dev": "nest start --watch",
"start:prod": "node dist/main",
"prisma:generate": "prisma generate",
"prisma:push": "prisma db push",
"prisma:migrate": "prisma migrate dev",
"prisma:seed": "ts-node prisma/seed.ts",
"lint": "nest lint",
"test": "jest --no-cache --verbose",
"test:watch": "jest --watch"
},
"keywords": [
"yuzhiran",
"ai",
"learning",
"platform"
],
"author": "北京宇之然科技中心",
"license": "UNLICENSED",
"private": true,
"prisma": {
"seed": "node prisma/seed.js"
},
"dependencies": {
"@nestjs/common": "^11.1.19",
"@nestjs/config": "^4.0.4",
"@nestjs/core": "^11.1.19",
"@nestjs/jwt": "^11.0.2",
"@nestjs/passport": "^11.0.5",
"@nestjs/platform-express": "^11.1.19",
"@nestjs/serve-static": "^5.0.5",
"@nestjs/swagger": "^11.4.2",
"@prisma/client": "^5.22.0",
"bcryptjs": "^3.0.3",
"class-transformer": "^0.5.1",
"class-validator": "^0.15.1",
"compression": "^1.8.1",
"helmet": "^8.1.0",
"passport": "^0.7.0",
"passport-jwt": "^4.0.1",
"prisma": "^5.22.0",
"reflect-metadata": "^0.2.2",
"rxjs": "^7.8.2",
"wechat-pay-nodejs": "^0.2.3"
},
"devDependencies": {
"@nestjs/cli": "^11.0.21",
"@nestjs/schematics": "^11.1.0",
"@nestjs/testing": "^11.1.19",
"@types/bcryptjs": "^2.4.6",
"@types/compression": "^1.8.1",
"@types/express": "^5.0.6",
"@types/jest": "^30.0.0",
"@types/multer": "^2.1.0",
"@types/node": "^20.19.40",
"@types/passport-jwt": "^4.0.1",
"@types/supertest": "^7.2.0",
"jest": "^30.4.0",
"supertest": "^7.2.2",
"ts-jest": "^29.4.9",
"ts-loader": "^9.5.7",
"ts-node": "^10.9.2",
"tsx": "^4.21.0",
"typescript": "5.3"
}
}
+511
View File
@@ -0,0 +1,511 @@
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "mysql"
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 {
id Int @id @default(autoincrement())
phone String? @unique
email String? @unique
passwordHash String?
nickname String?
avatar String?
bio String?
status UserStatus @default(ACTIVE)
memberPlan MemberPlan @default(FREE)
memberExpire DateTime?
sandboxDaily Int @default(10)
followerCount Int @default(0)
followingCount Int @default(0)
postCount Int @default(0)
lastLoginAt DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
deletedAt DateTime?
learnRecords LearnRecord[]
promptFavorites PromptFavorite[]
prompts Prompt[]
sandboxSessions SandboxSession[]
orders Order[]
subscriptions Subscription[]
posts Post[]
comments Comment[]
postLikes PostLike[]
followers Follow[] @relation("Following")
following Follow[] @relation("Follower")
createdCircles Circle[] @relation("CircleCreator")
circleMemberships CircleMember[]
organizationMemberships OrganizationMember[]
notifications Notification[]
@@map("users")
}
model Category {
id Int @id @default(autoincrement())
name String
slug String @unique
description String?
sortOrder Int @default(0)
parentId Int?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
parent Category? @relation("CategoryTree", fields: [parentId], references: [id])
children Category[] @relation("CategoryTree")
courses Course[]
prompts Prompt[]
contents Content[]
tools Tool[]
@@map("categories")
}
model Course {
id Int @id @default(autoincrement())
title String
description String?
cover String?
categoryId Int?
price Float @default(0)
isFree Boolean @default(true)
status ContentStatus @default(DRAFT)
sortOrder Int @default(0)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
deletedAt DateTime?
category Category? @relation(fields: [categoryId], references: [id])
chapters Chapter[]
progress LearnRecord[]
assignments CourseAssignment[]
@@map("courses")
}
model Chapter {
id Int @id @default(autoincrement())
courseId Int
title String
sortOrder Int @default(0)
createdAt DateTime @default(now())
course Course @relation(fields: [courseId], references: [id], onDelete: Cascade)
lessons Lesson[]
@@map("chapters")
}
model Lesson {
id Int @id @default(autoincrement())
chapterId Int
title String
content String?
videoUrl String?
duration Int?
sortOrder Int @default(0)
status ContentStatus @default(DRAFT)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
chapter Chapter @relation(fields: [chapterId], references: [id], onDelete: Cascade)
progress LearnRecord[]
@@map("lessons")
}
model LearnRecord {
id Int @id @default(autoincrement())
userId Int
courseId Int
lessonId Int
completed Boolean @default(false)
progress Float @default(0)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
course Course @relation(fields: [courseId], references: [id], onDelete: Cascade)
lesson Lesson @relation(fields: [lessonId], references: [id], onDelete: Cascade)
@@unique([userId, lessonId])
@@map("learn_records")
}
model Prompt {
id Int @id @default(autoincrement())
title String
content String
description String?
categoryId Int?
tags String?
authorId Int?
model String?
isPublic Boolean @default(true)
status ContentStatus @default(PUBLISHED)
viewCount Int @default(0)
likeCount Int @default(0)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
deletedAt DateTime?
category Category? @relation(fields: [categoryId], references: [id])
author User? @relation(fields: [authorId], references: [id])
favorites PromptFavorite[]
@@map("prompts")
}
model PromptFavorite {
id Int @id @default(autoincrement())
userId Int
promptId Int
createdAt DateTime @default(now())
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
prompt Prompt @relation(fields: [promptId], references: [id], onDelete: Cascade)
@@unique([userId, promptId])
@@map("prompt_favorites")
}
model Tool {
id Int @id @default(autoincrement())
name String
description String?
url String
icon String?
categoryId Int?
tags String?
isFeatured Boolean @default(false)
status ContentStatus @default(PUBLISHED)
viewCount Int @default(0)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
deletedAt DateTime?
category Category? @relation(fields: [categoryId], references: [id])
@@map("tools")
}
model Content {
id Int @id @default(autoincrement())
title String
summary String?
content String?
cover String?
categoryId Int?
tags String?
authorName String?
contentType String @default("article")
status ContentStatus @default(DRAFT)
viewCount Int @default(0)
isAiGenerated Boolean @default(false)
publishedAt DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
deletedAt DateTime?
category Category? @relation(fields: [categoryId], references: [id])
@@map("contents")
}
model AiModel {
id Int @id @default(autoincrement())
name String
provider String
description String?
capabilities String?
contextWindow Int?
maxTokens Int?
pricing String?
isFree Boolean @default(false)
isFeatured Boolean @default(false)
icon String?
sortOrder Int @default(0)
status String @default("ACTIVE")
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@map("ai_models")
}
model SandboxSession {
id Int @id @default(autoincrement())
userId Int
conversationId String @default("")
model String
title String @default("AI 对话")
messages String @db.Text
feedback String?
tokens Int @default(0)
duration Int @default(0)
createdAt DateTime @default(now())
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@unique([userId, conversationId])
@@index([userId, createdAt])
@@map("sandbox_sessions")
}
model Order {
id Int @id @default(autoincrement())
orderNo String @unique
userId Int
amount Float
planType String
status OrderStatus @default(PENDING)
payChannel String?
paidAt DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
user User @relation(fields: [userId], references: [id])
@@index([userId, status])
@@index([status, createdAt])
@@map("orders")
}
model Subscription {
id Int @id @default(autoincrement())
userId Int
plan MemberPlan
startDate DateTime
endDate DateTime
status String @default("ACTIVE")
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
user User @relation(fields: [userId], references: [id])
@@index([userId, status])
@@map("subscriptions")
}
model AdminUser {
id Int @id @default(autoincrement())
username String @unique
passwordHash String
nickname String?
role String @default("editor")
status String @default("ACTIVE")
lastLoginAt DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@map("admin_users")
}
model AdminLog {
id Int @id @default(autoincrement())
adminId Int
action String
target String?
detail String?
ip String?
createdAt DateTime @default(now())
@@map("admin_logs")
}
model Post {
id Int @id @default(autoincrement())
userId Int
title String
content String
tags String?
circleId Int?
status String @default("PUBLISHED")
viewCount Int @default(0)
likeCount Int @default(0)
commentCount Int @default(0)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
comments Comment[]
likes PostLike[]
circle Circle? @relation(fields: [circleId], references: [id])
@@index([userId])
@@index([status, createdAt])
@@index([circleId])
@@map("posts")
}
model Comment {
id Int @id @default(autoincrement())
postId Int
userId Int
content String
parentId Int?
status String @default("PUBLISHED")
reviewNote String?
reviewedAt DateTime?
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])
replies Comment[] @relation("CommentReply")
@@index([postId])
@@index([status])
@@map("comments")
}
model PostLike {
id Int @id @default(autoincrement())
postId Int
userId Int
createdAt DateTime @default(now())
post Post @relation(fields: [postId], references: [id], onDelete: Cascade)
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@unique([postId, userId])
@@map("post_likes")
}
model Circle {
id Int @id @default(autoincrement())
name String
description String?
tags String?
creatorId Int
isPublic Boolean @default(true)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
creator User @relation("CircleCreator", fields: [creatorId], references: [id])
members CircleMember[]
posts Post[]
@@map("circles")
}
model CircleMember {
id Int @id @default(autoincrement())
circleId Int
userId Int
role String @default("member")
joinedAt DateTime @default(now())
circle Circle @relation(fields: [circleId], references: [id], onDelete: Cascade)
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@unique([circleId, userId])
@@map("circle_members")
}
model Follow {
id Int @id @default(autoincrement())
followerId Int
followingId Int
createdAt DateTime @default(now())
follower User @relation("Follower", fields: [followerId], references: [id], onDelete: Cascade)
following User @relation("Following", fields: [followingId], references: [id], onDelete: Cascade)
@@unique([followerId, followingId])
@@map("follows")
}
model Organization {
id Int @id @default(autoincrement())
name String
description String?
contactName String?
contactPhone String?
logo String?
memberCount Int @default(0)
status String @default("ACTIVE")
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
members OrganizationMember[]
assignments CourseAssignment[]
@@map("organizations")
}
model OrganizationMember {
id Int @id @default(autoincrement())
organizationId Int
userId Int
role String @default("MEMBER")
status String @default("ACTIVE")
joinedAt DateTime @default(now())
organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@unique([organizationId, userId])
@@map("organization_members")
}
model CourseAssignment {
id Int @id @default(autoincrement())
organizationId Int
courseId Int
assignedBy Int
deadline DateTime?
createdAt DateTime @default(now())
organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
course Course @relation(fields: [courseId], references: [id], onDelete: Cascade)
@@map("course_assignments")
}
model Notification {
id Int @id @default(autoincrement())
userId Int
type String // 'like', 'comment', 'follow', 'system'
title String
content String?
link String?
relatedId Int?
isRead Boolean @default(false)
createdAt DateTime @default(now())
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@index([userId, isRead])
@@map("notifications")
}
+1
View File
@@ -0,0 +1 @@
export {};
+156
View File
@@ -0,0 +1,156 @@
"use strict";
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
var desc = Object.getOwnPropertyDescriptor(m, k);
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
desc = { enumerable: true, get: function() { return m[k]; } };
}
Object.defineProperty(o, k2, desc);
}) : (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
o[k2] = m[k];
}));
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
Object.defineProperty(o, "default", { enumerable: true, value: v });
}) : function(o, v) {
o["default"] = v;
});
var __importStar = (this && this.__importStar) || (function () {
var ownKeys = function(o) {
ownKeys = Object.getOwnPropertyNames || function (o) {
var ar = [];
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
return ar;
};
return ownKeys(o);
};
return function (mod) {
if (mod && mod.__esModule) return mod;
var result = {};
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
__setModuleDefault(result, mod);
return result;
};
})();
Object.defineProperty(exports, "__esModule", { value: true });
const client_1 = require("@prisma/client");
const bcrypt = __importStar(require("bcryptjs"));
const prisma = new client_1.PrismaClient();
async function main() {
const adminPassword = await bcrypt.hash('admin123456', 10);
await prisma.adminUser.upsert({
where: { username: 'admin' },
update: {},
create: {
username: 'admin',
passwordHash: adminPassword,
nickname: '超级管理员',
role: 'superadmin',
},
});
const categories = [
{ 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 categories) {
await prisma.category.upsert({
where: { slug: cat.slug },
update: {},
create: cat,
});
}
const basicsCategory = await prisma.category.findUnique({ where: { slug: 'ai-basics' } });
const promptCategory = await prisma.category.findUnique({ where: { slug: 'prompt-engineering' } });
if (basicsCategory) {
const course = await prisma.course.upsert({
where: { id: 1 },
update: {},
create: {
title: 'AI 通识课:零基础入门人工智能',
description: '面向零基础用户的 AI 入门课程,带你了解 AI 的基本概念、发展历程和实际应用。',
categoryId: basicsCategory.id,
isFree: true,
status: 'PUBLISHED',
sortOrder: 1,
chapters: {
create: [
{
title: '第一章:什么是人工智能',
sortOrder: 1,
lessons: {
create: [
{ title: 'AI 的定义与发展简史', content: '# AI 的定义\n\n人工智能...', sortOrder: 1, status: 'PUBLISHED' },
{ title: '机器学习 vs 深度学习', content: '# 机器学习\n\n...', sortOrder: 2, status: 'PUBLISHED' },
{ title: '大语言模型(LLM)是什么', content: '# 大语言模型\n\n...', sortOrder: 3, status: 'PUBLISHED' },
],
},
},
{
title: '第二章:主流 AI 工具介绍',
sortOrder: 2,
lessons: {
create: [
{ title: 'ChatGPT 与 GPT 系列', content: '# ChatGPT\n\n...', sortOrder: 1, status: 'PUBLISHED' },
{ title: 'Claude 系列模型', content: '# Claude\n\n...', sortOrder: 2, status: 'PUBLISHED' },
{ title: '国内大模型:通义千问、文心一言、GLM', content: '# 国内大模型\n\n...', sortOrder: 3, status: 'PUBLISHED' },
],
},
},
],
},
},
});
}
if (promptCategory) {
const prompts = [
{ title: '写周报助手', content: '请帮我写一份本周工作总结,我的工作是:[描述你的工作内容]。\n\n要求:\n1. 按重点项目分类\n2. 包含数据成果\n3. 列出下周计划\n4. 语言简洁专业', description: '帮你快速生成高质量周报', categoryId: promptCategory.id, tags: '办公,周报', model: '通用', status: 'PUBLISHED' },
{ title: '会议纪要生成', content: '请根据以下会议内容生成会议纪要:\n[粘贴会议内容]\n\n格式要求:\n- 时间\n- 参会人\n- 会议议题\n- 讨论要点\n- 待办事项及负责人', description: '快速整理会议内容为结构化纪要', categoryId: promptCategory.id, tags: '办公,会议', model: '通用', status: 'PUBLISHED' },
];
for (const prompt of prompts) {
await prisma.prompt.create({ data: prompt });
}
}
const toolsCategory = await prisma.category.findUnique({ where: { slug: 'ai-tools' } });
if (toolsCategory) {
const tools = [
{ name: 'ChatGPT', description: 'OpenAI 开发的对话式 AI 助手,支持文本生成、代码编写、分析等', url: 'https://chat.openai.com', categoryId: toolsCategory.id, tags: '对话,文本生成,代码', isFeatured: true, status: 'PUBLISHED' },
{ name: 'Claude', description: 'Anthropic 开发的 AI 助手,擅长深度分析、长文本处理', url: 'https://claude.ai', categoryId: toolsCategory.id, tags: '对话,分析,长文本', isFeatured: true, status: 'PUBLISHED' },
{ name: '通义千问', description: '阿里云开发的 AI 大模型,支持文本、图像、代码等多种任务', url: 'https://tongyi.aliyun.com', categoryId: toolsCategory.id, tags: '对话,文本,国内', isFeatured: true, status: 'PUBLISHED' },
{ name: '文心一言', description: '百度开发的 AI 对话产品,基于文心大模型', url: 'https://yiyan.baidu.com', categoryId: toolsCategory.id, tags: '对话,文本,国内', isFeatured: true, status: 'PUBLISHED' },
];
for (const tool of tools) {
await prisma.tool.create({ data: tool });
}
}
const models = [
{ name: 'GPT-4o', provider: 'OpenAI', description: 'OpenAI 的多模态旗舰模型,支持文本、图像、音频输入,性能全面领先', capabilities: '文本生成,图像理解,代码,分析,多语言', contextWindow: 128000, maxTokens: 4096, pricing: '付费 API', isFeatured: true, sortOrder: 1 },
{ name: 'GPT-4o-mini', provider: 'OpenAI', description: 'GPT-4o 的轻量版,速度快、成本低,适合日常对话和简单任务', capabilities: '文本生成,代码,分析', contextWindow: 128000, maxTokens: 16384, pricing: '付费 API', isFeatured: false, sortOrder: 2 },
{ name: 'Claude 3.5 Sonnet', provider: 'Anthropic', description: 'Anthropic 的旗舰模型,在编程、深度分析、长文本处理方面表现优异', capabilities: '文本生成,代码,分析,长文本,多语言', contextWindow: 200000, maxTokens: 8192, pricing: '付费 API', isFeatured: true, sortOrder: 3 },
{ name: 'DeepSeek-V3', provider: '深度求索', description: '国产开源大模型,推理能力强,数学和编程能力突出,性价比极高', capabilities: '文本生成,代码,数学推理,分析', contextWindow: 128000, maxTokens: 8192, pricing: '免费/付费 API', isFeatured: true, sortOrder: 4 },
{ name: 'DeepSeek-R1', provider: '深度求索', description: '专注推理的模型,擅长复杂逻辑推理、数学问题,思维链能力强大', capabilities: '推理,数学,逻辑,代码', contextWindow: 128000, maxTokens: 8192, pricing: '免费/付费 API', isFeatured: true, sortOrder: 5 },
{ name: '通义千问 2.5', provider: '阿里云', description: '阿里云推出的最新版 Qwen 模型,中英文能力优秀,支持图像理解', capabilities: '文本生成,图像理解,代码,多语言', contextWindow: 131072, maxTokens: 8192, pricing: '免费/付费 API', isFeatured: true, sortOrder: 6 },
{ name: 'GLM-4', provider: '智谱 AI', description: '智谱 AI 的第四代 GLM 模型,中文理解能力强,支持多模态和工具调用', capabilities: '文本生成,代码,工具调用,多模态', contextWindow: 128000, maxTokens: 4096, pricing: '免费/付费 API', isFeatured: false, sortOrder: 7 },
{ name: '文心一言 4.0', provider: '百度', description: '百度文心大模型 4.0,知识问答和中文理解能力强,支持多种格式', capabilities: '文本生成,知识问答,图像生成,代码', contextWindow: 8000, maxTokens: 4096, pricing: '付费 API', isFeatured: false, sortOrder: 8 },
{ name: 'Moonshot', provider: '月之暗面', description: 'Kimi 背后的模型,超长上下文窗口(200 万字),擅长长文档处理', capabilities: '长文本,文档分析,文本生成', contextWindow: 128000, maxTokens: 4096, pricing: '免费/付费 API', isFeatured: false, sortOrder: 9 },
{ name: 'Gemini 2.0 Flash', provider: 'Google', description: 'Google 的轻量高速模型,多模态能力强,支持图片/视频/音频理解', capabilities: '多模态,图像理解,音频,视频,代码', contextWindow: 1000000, maxTokens: 8192, pricing: '免费 API', isFree: true, isFeatured: false, sortOrder: 10 },
];
for (const model of models) {
await prisma.aiModel.create({ data: model });
}
console.log('Seed data created successfully!');
}
main()
.catch((e) => {
console.error(e);
process.exit(1);
})
.finally(async () => {
await prisma.$disconnect();
});
//# sourceMappingURL=seed.js.map
+1
View File
@@ -0,0 +1 @@
{"version":3,"file":"seed.js","sourceRoot":"","sources":["seed.ts"],"names":[],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AAAA,2CAA8C;AAC9C,iDAAmC;AAEnC,MAAM,MAAM,GAAG,IAAI,qBAAY,EAAE,CAAC;AAElC,KAAK,UAAU,IAAI;IAEjB,MAAM,aAAa,GAAG,MAAM,MAAM,CAAC,IAAI,CAAC,aAAa,EAAE,EAAE,CAAC,CAAC;IAC3D,MAAM,MAAM,CAAC,SAAS,CAAC,MAAM,CAAC;QAC5B,KAAK,EAAE,EAAE,QAAQ,EAAE,OAAO,EAAE;QAC5B,MAAM,EAAE,EAAE;QACV,MAAM,EAAE;YACN,QAAQ,EAAE,OAAO;YACjB,YAAY,EAAE,aAAa;YAC3B,QAAQ,EAAE,OAAO;YACjB,IAAI,EAAE,YAAY;SACnB;KACF,CAAC,CAAC;IAGH,MAAM,UAAU,GAAG;QACjB,EAAE,IAAI,EAAE,OAAO,EAAE,IAAI,EAAE,WAAW,EAAE,WAAW,EAAE,cAAc,EAAE;QACjE,EAAE,IAAI,EAAE,MAAM,EAAE,IAAI,EAAE,qBAAqB,EAAE,WAAW,EAAE,aAAa,EAAE;QACzE,EAAE,IAAI,EAAE,MAAM,EAAE,IAAI,EAAE,iBAAiB,EAAE,WAAW,EAAE,YAAY,EAAE;QACpE,EAAE,IAAI,EAAE,MAAM,EAAE,IAAI,EAAE,aAAa,EAAE,WAAW,EAAE,WAAW,EAAE;QAC/D,EAAE,IAAI,EAAE,MAAM,EAAE,IAAI,EAAE,WAAW,EAAE,WAAW,EAAE,YAAY,EAAE;QAC9D,EAAE,IAAI,EAAE,OAAO,EAAE,IAAI,EAAE,oBAAoB,EAAE,WAAW,EAAE,UAAU,EAAE;QACtE,EAAE,IAAI,EAAE,OAAO,EAAE,IAAI,EAAE,UAAU,EAAE,WAAW,EAAE,YAAY,EAAE;QAC9D,EAAE,IAAI,EAAE,MAAM,EAAE,IAAI,EAAE,oBAAoB,EAAE,WAAW,EAAE,UAAU,EAAE;KACtE,CAAC;IAEF,KAAK,MAAM,GAAG,IAAI,UAAU,EAAE,CAAC;QAC7B,MAAM,MAAM,CAAC,QAAQ,CAAC,MAAM,CAAC;YAC3B,KAAK,EAAE,EAAE,IAAI,EAAE,GAAG,CAAC,IAAI,EAAE;YACzB,MAAM,EAAE,EAAE;YACV,MAAM,EAAE,GAAG;SACZ,CAAC,CAAC;IACL,CAAC;IAGD,MAAM,cAAc,GAAG,MAAM,MAAM,CAAC,QAAQ,CAAC,UAAU,CAAC,EAAE,KAAK,EAAE,EAAE,IAAI,EAAE,WAAW,EAAE,EAAE,CAAC,CAAC;IAC1F,MAAM,cAAc,GAAG,MAAM,MAAM,CAAC,QAAQ,CAAC,UAAU,CAAC,EAAE,KAAK,EAAE,EAAE,IAAI,EAAE,oBAAoB,EAAE,EAAE,CAAC,CAAC;IAEnG,IAAI,cAAc,EAAE,CAAC;QACnB,MAAM,MAAM,GAAG,MAAM,MAAM,CAAC,MAAM,CAAC,MAAM,CAAC;YACxC,KAAK,EAAE,EAAE,EAAE,EAAE,CAAC,EAAE;YAChB,MAAM,EAAE,EAAE;YACV,MAAM,EAAE;gBACN,KAAK,EAAE,kBAAkB;gBACzB,WAAW,EAAE,2CAA2C;gBACxD,UAAU,EAAE,cAAc,CAAC,EAAE;gBAC7B,MAAM,EAAE,IAAI;gBACZ,MAAM,EAAE,WAAW;gBACnB,SAAS,EAAE,CAAC;gBACZ,QAAQ,EAAE;oBACR,MAAM,EAAE;wBACN;4BACE,KAAK,EAAE,aAAa;4BACpB,SAAS,EAAE,CAAC;4BACZ,OAAO,EAAE;gCACP,MAAM,EAAE;oCACN,EAAE,KAAK,EAAE,aAAa,EAAE,OAAO,EAAE,qBAAqB,EAAE,SAAS,EAAE,CAAC,EAAE,MAAM,EAAE,WAAW,EAAE;oCAC3F,EAAE,KAAK,EAAE,cAAc,EAAE,OAAO,EAAE,eAAe,EAAE,SAAS,EAAE,CAAC,EAAE,MAAM,EAAE,WAAW,EAAE;oCACtF,EAAE,KAAK,EAAE,eAAe,EAAE,OAAO,EAAE,gBAAgB,EAAE,SAAS,EAAE,CAAC,EAAE,MAAM,EAAE,WAAW,EAAE;iCACzF;6BACF;yBACF;wBACD;4BACE,KAAK,EAAE,gBAAgB;4BACvB,SAAS,EAAE,CAAC;4BACZ,OAAO,EAAE;gCACP,MAAM,EAAE;oCACN,EAAE,KAAK,EAAE,kBAAkB,EAAE,OAAO,EAAE,kBAAkB,EAAE,SAAS,EAAE,CAAC,EAAE,MAAM,EAAE,WAAW,EAAE;oCAC7F,EAAE,KAAK,EAAE,aAAa,EAAE,OAAO,EAAE,iBAAiB,EAAE,SAAS,EAAE,CAAC,EAAE,MAAM,EAAE,WAAW,EAAE;oCACvF,EAAE,KAAK,EAAE,qBAAqB,EAAE,OAAO,EAAE,gBAAgB,EAAE,SAAS,EAAE,CAAC,EAAE,MAAM,EAAE,WAAW,EAAE;iCAC/F;6BACF;yBACF;qBACF;iBACF;aACF;SACF,CAAC,CAAC;IACL,CAAC;IAGD,IAAI,cAAc,EAAE,CAAC;QACnB,MAAM,OAAO,GAAG;YACd,EAAE,KAAK,EAAE,OAAO,EAAE,OAAO,EAAE,oFAAoF,EAAE,WAAW,EAAE,aAAa,EAAE,UAAU,EAAE,cAAc,CAAC,EAAE,EAAE,IAAI,EAAE,OAAO,EAAE,KAAK,EAAE,IAAI,EAAE,MAAM,EAAE,WAAoB,EAAE;YACtO,EAAE,KAAK,EAAE,QAAQ,EAAE,OAAO,EAAE,8EAA8E,EAAE,WAAW,EAAE,gBAAgB,EAAE,UAAU,EAAE,cAAc,CAAC,EAAE,EAAE,IAAI,EAAE,OAAO,EAAE,KAAK,EAAE,IAAI,EAAE,MAAM,EAAE,WAAoB,EAAE;SACrO,CAAC;QAEF,KAAK,MAAM,MAAM,IAAI,OAAO,EAAE,CAAC;YAC7B,MAAM,MAAM,CAAC,MAAM,CAAC,MAAM,CAAC,EAAE,IAAI,EAAE,MAAM,EAAE,CAAC,CAAC;QAC/C,CAAC;IACH,CAAC;IAGD,MAAM,aAAa,GAAG,MAAM,MAAM,CAAC,QAAQ,CAAC,UAAU,CAAC,EAAE,KAAK,EAAE,EAAE,IAAI,EAAE,UAAU,EAAE,EAAE,CAAC,CAAC;IACxF,IAAI,aAAa,EAAE,CAAC;QAClB,MAAM,KAAK,GAAG;YACZ,EAAE,IAAI,EAAE,SAAS,EAAE,WAAW,EAAE,qCAAqC,EAAE,GAAG,EAAE,yBAAyB,EAAE,UAAU,EAAE,aAAa,CAAC,EAAE,EAAE,IAAI,EAAE,YAAY,EAAE,UAAU,EAAE,IAAI,EAAE,MAAM,EAAE,WAAoB,EAAE;YACzM,EAAE,IAAI,EAAE,QAAQ,EAAE,WAAW,EAAE,kCAAkC,EAAE,GAAG,EAAE,mBAAmB,EAAE,UAAU,EAAE,aAAa,CAAC,EAAE,EAAE,IAAI,EAAE,WAAW,EAAE,UAAU,EAAE,IAAI,EAAE,MAAM,EAAE,WAAoB,EAAE;YAC9L,EAAE,IAAI,EAAE,MAAM,EAAE,WAAW,EAAE,+BAA+B,EAAE,GAAG,EAAE,2BAA2B,EAAE,UAAU,EAAE,aAAa,CAAC,EAAE,EAAE,IAAI,EAAE,UAAU,EAAE,UAAU,EAAE,IAAI,EAAE,MAAM,EAAE,WAAoB,EAAE;YAChM,EAAE,IAAI,EAAE,MAAM,EAAE,WAAW,EAAE,uBAAuB,EAAE,GAAG,EAAE,yBAAyB,EAAE,UAAU,EAAE,aAAa,CAAC,EAAE,EAAE,IAAI,EAAE,UAAU,EAAE,UAAU,EAAE,IAAI,EAAE,MAAM,EAAE,WAAoB,EAAE;SACvL,CAAC;QAEF,KAAK,MAAM,IAAI,IAAI,KAAK,EAAE,CAAC;YACzB,MAAM,MAAM,CAAC,IAAI,CAAC,MAAM,CAAC,EAAE,IAAI,EAAE,IAAI,EAAE,CAAC,CAAC;QAC3C,CAAC;IACH,CAAC;IAED,OAAO,CAAC,GAAG,CAAC,iCAAiC,CAAC,CAAC;AACjD,CAAC;AAED,IAAI,EAAE;KACH,KAAK,CAAC,CAAC,CAAC,EAAE,EAAE;IACX,OAAO,CAAC,KAAK,CAAC,CAAC,CAAC,CAAC;IACjB,OAAO,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC;AAClB,CAAC,CAAC;KACD,OAAO,CAAC,KAAK,IAAI,EAAE;IAClB,MAAM,MAAM,CAAC,WAAW,EAAE,CAAC;AAC7B,CAAC,CAAC,CAAC"}
+122
View File
@@ -0,0 +1,122 @@
import { PrismaClient } from '@prisma/client';
import * as bcrypt from 'bcryptjs';
const prisma = new PrismaClient();
async function main() {
// 创建管理员
const adminPassword = await bcrypt.hash('admin123456', 10);
await prisma.adminUser.upsert({
where: { username: 'admin' },
update: {},
create: {
username: 'admin',
passwordHash: adminPassword,
nickname: '超级管理员',
role: 'superadmin',
},
});
// 创建分类
const categories = [
{ 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 categories) {
await prisma.category.upsert({
where: { slug: cat.slug },
update: {},
create: cat,
});
}
// 创建示例课程
const basicsCategory = await prisma.category.findUnique({ where: { slug: 'ai-basics' } });
const promptCategory = await prisma.category.findUnique({ where: { slug: 'prompt-engineering' } });
if (basicsCategory) {
const course = await prisma.course.upsert({
where: { id: 1 },
update: {},
create: {
title: 'AI 通识课:零基础入门人工智能',
description: '面向零基础用户的 AI 入门课程,带你了解 AI 的基本概念、发展历程和实际应用。',
categoryId: basicsCategory.id,
isFree: true,
status: 'PUBLISHED',
sortOrder: 1,
chapters: {
create: [
{
title: '第一章:什么是人工智能',
sortOrder: 1,
lessons: {
create: [
{ title: 'AI 的定义与发展简史', content: '# AI 的定义\n\n人工智能...', sortOrder: 1, status: 'PUBLISHED' },
{ title: '机器学习 vs 深度学习', content: '# 机器学习\n\n...', sortOrder: 2, status: 'PUBLISHED' },
{ title: '大语言模型(LLM)是什么', content: '# 大语言模型\n\n...', sortOrder: 3, status: 'PUBLISHED' },
],
},
},
{
title: '第二章:主流 AI 工具介绍',
sortOrder: 2,
lessons: {
create: [
{ title: 'ChatGPT 与 GPT 系列', content: '# ChatGPT\n\n...', sortOrder: 1, status: 'PUBLISHED' },
{ title: 'Claude 系列模型', content: '# Claude\n\n...', sortOrder: 2, status: 'PUBLISHED' },
{ title: '国内大模型:通义千问、文心一言、GLM', content: '# 国内大模型\n\n...', sortOrder: 3, status: 'PUBLISHED' },
],
},
},
],
},
},
});
}
// 创建示例提示词
if (promptCategory) {
const prompts = [
{ title: '写周报助手', content: '请帮我写一份本周工作总结,我的工作是:[描述你的工作内容]。\n\n要求:\n1. 按重点项目分类\n2. 包含数据成果\n3. 列出下周计划\n4. 语言简洁专业', description: '帮你快速生成高质量周报', categoryId: promptCategory.id, tags: '办公,周报', model: '通用', status: 'PUBLISHED' as const },
{ title: '会议纪要生成', content: '请根据以下会议内容生成会议纪要:\n[粘贴会议内容]\n\n格式要求:\n- 时间\n- 参会人\n- 会议议题\n- 讨论要点\n- 待办事项及负责人', description: '快速整理会议内容为结构化纪要', categoryId: promptCategory.id, tags: '办公,会议', model: '通用', status: 'PUBLISHED' as const },
];
for (const prompt of prompts) {
await prisma.prompt.create({ data: prompt });
}
}
// 创建示例 AI 工具
const toolsCategory = await prisma.category.findUnique({ where: { slug: 'ai-tools' } });
if (toolsCategory) {
const tools = [
{ name: 'ChatGPT', description: 'OpenAI 开发的对话式 AI 助手,支持文本生成、代码编写、分析等', url: 'https://chat.openai.com', categoryId: toolsCategory.id, tags: '对话,文本生成,代码', isFeatured: true, status: 'PUBLISHED' as const },
{ name: 'Claude', description: 'Anthropic 开发的 AI 助手,擅长深度分析、长文本处理', url: 'https://claude.ai', categoryId: toolsCategory.id, tags: '对话,分析,长文本', isFeatured: true, status: 'PUBLISHED' as const },
{ name: '通义千问', description: '阿里云开发的 AI 大模型,支持文本、图像、代码等多种任务', url: 'https://tongyi.aliyun.com', categoryId: toolsCategory.id, tags: '对话,文本,国内', isFeatured: true, status: 'PUBLISHED' as const },
{ name: '文心一言', description: '百度开发的 AI 对话产品,基于文心大模型', url: 'https://yiyan.baidu.com', categoryId: toolsCategory.id, tags: '对话,文本,国内', isFeatured: true, status: 'PUBLISHED' as const },
];
for (const tool of tools) {
await prisma.tool.create({ data: tool });
}
}
console.log('Seed data created successfully!');
}
main()
.catch((e) => {
console.error(e);
process.exit(1);
})
.finally(async () => {
await prisma.$disconnect();
});
+9
View File
@@ -0,0 +1,9 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"rootDir": ".",
"outDir": "./dist-prisma",
"types": ["node"]
},
"include": ["prisma/**/*.ts"]
}
+49
View File
@@ -0,0 +1,49 @@
import { Module } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config';
import { PrismaModule } from './prisma/prisma.module';
import { AuthModule } from './modules/auth/auth.module';
import { UsersModule } from './modules/users/users.module';
import { CoursesModule } from './modules/courses/courses.module';
import { ContentsModule } from './modules/contents/contents.module';
import { PromptsModule } from './modules/prompts/prompts.module';
import { ToolsModule } from './modules/tools/tools.module';
import { SandboxModule } from './modules/sandbox/sandbox.module';
import { OrdersModule } from './modules/orders/orders.module';
import { AdminModule } from './modules/admin/admin.module';
import { PaymentModule } from './modules/payment/payment.module';
import { CategoriesModule } from './modules/categories/categories.module';
import { DashboardModule } from './modules/dashboard/dashboard.module';
import { SearchModule } from './modules/search/search.module';
import { ModelsModule } from './modules/models/models.module';
import { UploadModule } from './modules/upload/upload.module';
import { CommunityModule } from './modules/community/community.module';
import { EnterpriseModule } from './modules/enterprise/enterprise.module';
import { NotificationModule } from './modules/notifications/notification.module';
import { LearningModule } from './modules/learning/learning.module';
@Module({
imports: [
ConfigModule.forRoot({ isGlobal: true }),
PrismaModule,
AuthModule,
UsersModule,
CoursesModule,
ContentsModule,
PromptsModule,
ToolsModule,
SandboxModule,
OrdersModule,
AdminModule,
PaymentModule,
CategoriesModule,
DashboardModule,
SearchModule,
ModelsModule,
UploadModule,
CommunityModule,
EnterpriseModule,
NotificationModule,
LearningModule,
],
})
export class AppModule {}
@@ -0,0 +1,31 @@
import { ExceptionFilter, Catch, ArgumentsHost, HttpException, HttpStatus, Logger } from '@nestjs/common';
import { Response } from 'express';
@Catch()
export class GlobalExceptionFilter implements ExceptionFilter {
private readonly logger = new Logger(GlobalExceptionFilter.name);
catch(exception: unknown, host: ArgumentsHost) {
const ctx = host.switchToHttp();
const response = ctx.getResponse<Response>();
let status = HttpStatus.INTERNAL_SERVER_ERROR;
let message = '服务器内部错误';
if (exception instanceof HttpException) {
status = exception.getStatus();
const res = exception.getResponse();
message = typeof res === 'string' ? res : (res as any).message || message;
if (Array.isArray(message)) message = message[0];
}
this.logger.error(`[${status}] ${message}`, exception instanceof Error ? exception.stack : '');
response.status(status).json({
code: status,
message,
data: null,
timestamp: new Date().toISOString(),
});
}
}
+2
View File
@@ -0,0 +1,2 @@
export { GlobalExceptionFilter } from './filters/global-exception.filter';
export { LoggingInterceptor } from './interceptors/logging.interceptor';
@@ -0,0 +1,22 @@
import { Injectable, NestInterceptor, ExecutionContext, CallHandler, Logger } from '@nestjs/common';
import { Observable } from 'rxjs';
import { tap } from 'rxjs/operators';
@Injectable()
export class LoggingInterceptor implements NestInterceptor {
private readonly logger = new Logger('HTTP');
intercept(context: ExecutionContext, next: CallHandler): Observable<any> {
const request = context.switchToHttp().getRequest();
const { method, url } = request;
const now = Date.now();
return next.handle().pipe(
tap(() => {
const response = context.switchToHttp().getResponse();
const ms = Date.now() - now;
this.logger.log(`${method} ${url} ${response.statusCode} ${ms}ms`);
}),
);
}
}
+52
View File
@@ -0,0 +1,52 @@
import { NestFactory } from '@nestjs/core';
import { ValidationPipe } from '@nestjs/common';
import { SwaggerModule, DocumentBuilder } from '@nestjs/swagger';
import { NestExpressApplication } from '@nestjs/platform-express';
import { join } from 'path';
import helmet from 'helmet';
import * as compression from 'compression';
import { AppModule } from './app.module';
import { GlobalExceptionFilter, LoggingInterceptor } from './common';
async function bootstrap() {
const app = await NestFactory.create<NestExpressApplication>(AppModule, {
logger: ['log', 'error', 'warn', 'debug', 'verbose'],
});
app.use(helmet({
crossOriginResourcePolicy: { policy: 'cross-origin' },
contentSecurityPolicy: false,
}));
app.use(compression());
app.useStaticAssets(join(process.cwd(), 'uploads'), { prefix: '/uploads' });
app.setGlobalPrefix('api/v1');
app.enableCors({
origin: true,
credentials: true,
methods: ['GET', 'POST', 'PUT', 'DELETE', 'PATCH'],
allowedHeaders: ['Content-Type', 'Authorization'],
maxAge: 0,
});
app.useGlobalPipes(new ValidationPipe({ whitelist: true, transform: true }));
app.useGlobalFilters(new GlobalExceptionFilter());
app.useGlobalInterceptors(new LoggingInterceptor());
const config = new DocumentBuilder()
.setTitle('宇之然 AI API')
.setDescription('宇之然 AI 学习与实践平台 API 文档')
.setVersion('1.0')
.addBearerAuth()
.build();
const document = SwaggerModule.createDocument(app, config);
SwaggerModule.setup('api/docs', app, document);
const port = process.env.PORT || 4000;
await app.listen(port);
console.log(`宇之然 AI API 服务已启动: http://localhost:${port}`);
console.log(`API 文档: http://localhost:${port}/api/docs`);
}
bootstrap();
@@ -0,0 +1,86 @@
import { Controller, Post, Get, Put, Body, UseGuards, Req, Param } from '@nestjs/common';
import { ApiTags, ApiBearerAuth } from '@nestjs/swagger';
import { AuthGuard } from '@nestjs/passport';
import { AdminService } from './admin.service';
import { AdminGuard } from './admin.guard';
import { CoursesService } from '../courses/courses.service';
import { PrismaService } from '../../prisma/prisma.service';
@ApiTags('管理后台')
@Controller('admin')
export class AdminController {
constructor(
private adminService: AdminService,
private coursesService: CoursesService,
private prisma: PrismaService,
) {}
@Post('login')
async login(@Body() body: { username: string; password: string }) {
return this.adminService.login(body.username, body.password);
}
@Get('dashboard')
@UseGuards(AuthGuard('jwt'), AdminGuard)
@ApiBearerAuth()
async dashboard() {
return this.adminService.getDashboard();
}
@Get('orders')
@UseGuards(AuthGuard('jwt'), AdminGuard)
@ApiBearerAuth()
async orders(@Req() req: any) {
const items = await this.prisma.order.findMany({
orderBy: { createdAt: 'desc' },
take: 100,
include: { user: { select: { id: true, nickname: true, phone: true } } },
});
return { items };
}
@Put('courses/:id/chapters')
@UseGuards(AuthGuard('jwt'), AdminGuard)
@ApiBearerAuth()
async updateCourseChapters(@Param('id') id: string, @Body() body: any) {
return this.coursesService.updateWithChapters(+id, body);
}
@Get('comments/pending')
@UseGuards(AuthGuard('jwt'), AdminGuard)
@ApiBearerAuth()
async pendingComments() {
const items = await this.prisma.comment.findMany({
where: { status: 'PENDING_REVIEW' },
include: {
user: { select: { id: true, nickname: true, avatar: true } },
post: { select: { id: true, title: true } },
},
orderBy: { createdAt: 'desc' },
take: 50,
});
return { items };
}
@Put('comments/:id/approve')
@UseGuards(AuthGuard('jwt'), AdminGuard)
@ApiBearerAuth()
async approveComment(@Param('id') id: string) {
await this.prisma.comment.update({
where: { id: parseInt(id) },
data: { status: 'PUBLISHED', reviewedAt: new Date() },
});
return { ok: true };
}
@Put('comments/:id/reject')
@UseGuards(AuthGuard('jwt'), AdminGuard)
@ApiBearerAuth()
async rejectComment(@Param('id') id: string, @Body() body: { reason?: string }) {
await this.prisma.comment.update({
where: { id: parseInt(id) },
data: { status: 'REJECTED', reviewNote: body.reason || '违规内容', reviewedAt: new Date() },
});
return { ok: true };
}
}
+12
View File
@@ -0,0 +1,12 @@
import { Injectable, CanActivate, ExecutionContext, ForbiddenException } from '@nestjs/common';
@Injectable()
export class AdminGuard implements CanActivate {
canActivate(context: ExecutionContext): boolean {
const request = context.switchToHttp().getRequest();
if (!request.user?.isAdmin) {
throw new ForbiddenException('无管理员权限');
}
return true;
}
}
+15
View File
@@ -0,0 +1,15 @@
import { Module } from '@nestjs/common';
import { JwtModule } from '@nestjs/jwt';
import { AdminController } from './admin.controller';
import { AdminService } from './admin.service';
import { AdminGuard } from './admin.guard';
import { CoursesModule } from '../courses/courses.module';
import { AuthModule } from '../auth/auth.module';
@Module({
imports: [CoursesModule, AuthModule],
controllers: [AdminController],
providers: [AdminService, AdminGuard],
exports: [AdminService],
})
export class AdminModule {}
@@ -0,0 +1,56 @@
import { Injectable, UnauthorizedException } from '@nestjs/common';
import { JwtService } from '@nestjs/jwt';
import * as bcrypt from 'bcryptjs';
import { PrismaService } from '../../prisma/prisma.service';
@Injectable()
export class AdminService {
constructor(
private prisma: PrismaService,
private jwtService: JwtService,
) {}
async login(username: string, password: string) {
const admin = await this.prisma.adminUser.findUnique({ where: { username } });
if (!admin || admin.status !== 'ACTIVE') {
throw new UnauthorizedException('管理员账号不可用');
}
const isValid = await bcrypt.compare(password, admin.passwordHash);
if (!isValid) {
throw new UnauthorizedException('密码错误');
}
await this.prisma.adminUser.update({
where: { id: admin.id },
data: { lastLoginAt: new Date() },
});
const token = this.jwtService.sign(
{ sub: admin.id, type: 'admin' },
{ expiresIn: '8h' },
);
return { token, id: admin.id, username: admin.username, role: admin.role };
}
async getDashboard() {
const [userCount, courseCount, contentCount, promptCount, orderCount] = await Promise.all([
this.prisma.user.count({ where: { deletedAt: null } }),
this.prisma.course.count({ where: { deletedAt: null } }),
this.prisma.content.count({ where: { deletedAt: null, status: 'PUBLISHED' } }),
this.prisma.prompt.count({ where: { deletedAt: null, status: 'PUBLISHED' } }),
this.prisma.order.count(),
]);
return {
stats: { userCount, courseCount, contentCount, promptCount, orderCount },
};
}
async logAction(adminId: number, action: string, target?: string, detail?: string, ip?: string) {
return this.prisma.adminLog.create({
data: { adminId, action, target, detail, ip },
});
}
}
@@ -0,0 +1,172 @@
import { Test, TestingModule } from '@nestjs/testing';
import { UnauthorizedException } from '@nestjs/common';
import { JwtService } from '@nestjs/jwt';
import * as bcrypt from 'bcryptjs';
import { AdminService } from '../admin.service';
import { PrismaService } from '../../../prisma/prisma.service';
// Mock bcrypt
jest.mock('bcryptjs', () => ({
compare: jest.fn(),
hash: jest.fn(),
}));
describe('AdminService', () => {
let service: AdminService;
let prisma: PrismaService;
const mockPrisma = {
adminUser: {
findUnique: jest.fn(),
update: jest.fn(),
},
user: { count: jest.fn() },
course: { count: jest.fn() },
content: { count: jest.fn() },
prompt: { count: jest.fn() },
order: { count: jest.fn() },
adminLog: { create: jest.fn() },
};
const mockJwtService = {
sign: jest.fn().mockReturnValue('mock-jwt-token'),
};
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [
AdminService,
{ provide: PrismaService, useValue: mockPrisma },
{ provide: JwtService, useValue: mockJwtService },
],
}).compile();
service = module.get<AdminService>(AdminService);
prisma = module.get<PrismaService>(PrismaService);
jest.clearAllMocks();
});
describe('login', () => {
const mockAdmin = {
id: 1,
username: 'admin',
passwordHash: '$2a$10$hashed',
role: 'superadmin',
status: 'ACTIVE',
};
it('should login successfully', async () => {
mockPrisma.adminUser.findUnique.mockResolvedValue(mockAdmin);
jest.spyOn(bcrypt, 'compare').mockResolvedValue(true as never);
mockPrisma.adminUser.update.mockResolvedValue(mockAdmin);
const result = await service.login('admin', 'admin123456');
expect(result).toEqual(expect.objectContaining({ id: 1, username: 'admin', role: 'superadmin' }));
expect(mockPrisma.adminUser.update).toHaveBeenCalledWith(
expect.objectContaining({
where: { id: 1 },
data: { lastLoginAt: expect.any(Date) },
})
);
});
it('should throw on wrong password', async () => {
mockPrisma.adminUser.findUnique.mockResolvedValue(mockAdmin);
jest.spyOn(bcrypt, 'compare').mockResolvedValue(false as never);
await expect(service.login('admin', 'wrongpass')).rejects.toThrow(UnauthorizedException);
});
it('should throw when admin not found', async () => {
mockPrisma.adminUser.findUnique.mockResolvedValue(null);
await expect(service.login('unknown', 'pass')).rejects.toThrow(UnauthorizedException);
});
it('should throw when admin is inactive', async () => {
mockPrisma.adminUser.findUnique.mockResolvedValue({ ...mockAdmin, status: 'INACTIVE' });
await expect(service.login('admin', 'pass')).rejects.toThrow(UnauthorizedException);
});
});
describe('getDashboard', () => {
it('should return all stats', async () => {
mockPrisma.user.count.mockResolvedValue(100);
mockPrisma.course.count.mockResolvedValue(20);
mockPrisma.content.count.mockResolvedValue(45);
mockPrisma.prompt.count.mockResolvedValue(30);
mockPrisma.order.count.mockResolvedValue(15);
const result = await service.getDashboard();
expect(result).toEqual({
stats: {
userCount: 100,
courseCount: 20,
contentCount: 45,
promptCount: 30,
orderCount: 15,
},
});
});
it('should return zeroes when no data', async () => {
mockPrisma.user.count.mockResolvedValue(0);
mockPrisma.course.count.mockResolvedValue(0);
mockPrisma.content.count.mockResolvedValue(0);
mockPrisma.prompt.count.mockResolvedValue(0);
mockPrisma.order.count.mockResolvedValue(0);
const result = await service.getDashboard();
expect(result).toEqual({
stats: {
userCount: 0, courseCount: 0, contentCount: 0,
promptCount: 0, orderCount: 0,
},
});
});
it('should filter deleted users and courses', async () => {
mockPrisma.user.count.mockResolvedValue(50);
mockPrisma.course.count.mockResolvedValue(10);
mockPrisma.content.count.mockResolvedValue(5);
mockPrisma.prompt.count.mockResolvedValue(3);
mockPrisma.order.count.mockResolvedValue(2);
await service.getDashboard();
expect(mockPrisma.user.count).toHaveBeenCalledWith({ where: { deletedAt: null } });
expect(mockPrisma.course.count).toHaveBeenCalledWith({ where: { deletedAt: null } });
expect(mockPrisma.content.count).toHaveBeenCalledWith({
where: { deletedAt: null, status: 'PUBLISHED' },
});
});
});
describe('logAction', () => {
it('should create an admin log entry', async () => {
const mockLog = { id: 1, adminId: 1, action: 'login', target: null, detail: null, ip: null };
mockPrisma.adminLog.create.mockResolvedValue(mockLog);
const result = await service.logAction(1, 'login');
expect(result).toEqual(mockLog);
expect(mockPrisma.adminLog.create).toHaveBeenCalledWith({
data: { adminId: 1, action: 'login', target: undefined, detail: undefined, ip: undefined },
});
});
it('should create log with full details', async () => {
mockPrisma.adminLog.create.mockResolvedValue({ id: 2 });
await service.logAction(1, 'update', 'courses/1', '修改课程标题', '127.0.0.1');
expect(mockPrisma.adminLog.create).toHaveBeenCalledWith({
data: { adminId: 1, action: 'update', target: 'courses/1', detail: '修改课程标题', ip: '127.0.0.1' },
});
});
});
});
@@ -0,0 +1,158 @@
import { Injectable, Logger } from '@nestjs/common';
interface ChatMessage {
role: 'user' | 'assistant' | 'system';
content: string;
}
export interface ChatOptions {
temperature?: number;
top_p?: number;
max_tokens?: number;
}
interface AIProvider {
name: string;
chat(messages: ChatMessage[], options?: ChatOptions): Promise<string>;
}
@Injectable()
export class AIGatewayService {
private readonly logger = new Logger(AIGatewayService.name);
private providers: Map<string, AIProvider> = new Map();
constructor() {
this.registerProviders();
}
private registerProviders() {
if (process.env.OPENAI_API_KEY) {
let apiUrl = process.env.OPENAI_API_URL || 'https://api.openai.com/v1/chat/completions';
if (!apiUrl.endsWith('/chat/completions')) {
apiUrl = apiUrl.replace(/\/+$/, '') + '/chat/completions';
}
const defaultModel = process.env.OPENAI_MODEL || 'gpt-3.5-turbo';
this.providers.set('openai', new OpenAICompatibleProvider(
process.env.OPENAI_API_KEY!,
apiUrl,
defaultModel,
'OpenAI 兼容接口',
));
this.logger.log(`OpenAI 兼容接口已注册: ${defaultModel}`);
}
if (process.env.OPENCODE_API_KEY) {
let apiUrl = process.env.OPENCODE_API_URL || 'https://opencode.ai/zen/go/v1';
if (!apiUrl.endsWith('/chat/completions')) {
apiUrl = apiUrl.replace(/\/+$/, '') + '/chat/completions';
}
const defaultModel = process.env.OPENCODE_MODEL || 'deepseek-v4-flash';
this.providers.set('opencode', new OpenAICompatibleProvider(
process.env.OPENCODE_API_KEY!,
apiUrl,
defaultModel,
'OpenCode Go',
));
this.logger.log(`OpenCode Go 已注册: ${defaultModel}`);
}
}
async chat(model: string, messages: ChatMessage[], options?: ChatOptions): Promise<string> {
const modelMap: Record<string, string> = {
'general': 'openai',
'openai': 'openai',
'gpt-3.5': 'openai',
'gpt-4': 'openai',
'longcat': 'openai',
'meituan/longcat-flash-lite': 'openai',
'opencode-go': 'opencode',
'opencode': 'opencode',
'deepseek-v4-flash': 'opencode',
};
const providerKey = modelMap[model.toLowerCase()] || (model.includes('/') ? 'openai' : model);
const provider = this.providers.get(providerKey);
if (provider) {
try {
const reply = await provider.chat(messages, options);
if (typeof reply !== 'string' || reply.length === 0) {
throw new Error(`AI 返回内容为空: ${JSON.stringify(reply)}`);
}
return reply;
} catch (err: any) {
this.logger.error(`${provider.name} 调用失败: ${err.message}`);
return this.fallback(messages);
}
}
return this.fallback(messages);
}
private fallback(messages: ChatMessage[]): string {
const lastMsg = messages[messages.length - 1]?.content || '';
const mockReplies: Record<string, string> = {
'你好': '你好!我是宇之然 AI 助手,很高兴为你服务!',
'hello': 'Hello! I am YuZhiRan AI assistant, nice to meet you!',
};
for (const [key, reply] of Object.entries(mockReplies)) {
if (lastMsg.toLowerCase().includes(key)) {
return reply;
}
}
if (lastMsg.includes('提示词') || lastMsg.includes('prompt')) {
return '好的提示词需要明确角色、任务、输出格式和约束条件。例如:"你是一名专业的文案编辑,请帮我优化以下产品描述,要求语言简洁有力,突出产品核心卖点,控制在200字以内。"';
}
if (lastMsg.includes('模型') || lastMsg.includes('大模型')) {
return '目前主流的 AI 大模型包括:OpenAI 的 GPT 系列、Anthropic 的 Claude 系列、Google 的 Gemini 系列,以及国内的 DeepSeek、通义千问、文心一言、GLM 等。各模型在语言理解、代码生成、逻辑推理等方面各有优势。';
}
const names = Array.from(this.providers.values()).map(p => p.name).join('、');
return `我是宇之然 AI 助手。关于"${lastMsg.slice(0, 50)}..."的问题,我已收到。当前 AI 沙箱处于模拟模式,请配置 API Key 以获取真实回复。已配置的 API:${names}`;
}
getRegisteredProviders(): string[] {
return Array.from(this.providers.keys());
}
}
class OpenAICompatibleProvider implements AIProvider {
name: string;
private apiKey: string;
private apiUrl: string;
private defaultModel: string;
constructor(apiKey: string, apiUrl: string, defaultModel: string, name?: string) {
this.apiKey = apiKey;
this.apiUrl = apiUrl;
this.defaultModel = defaultModel;
this.name = name || 'OpenAI 兼容接口';
}
async chat(messages: ChatMessage[], options?: ChatOptions): Promise<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,
}),
});
if (!res.ok) {
throw new Error(`${this.name} API error: ${res.status} ${await res.text()}`);
}
const data = await res.json() as any;
return data.choices[0].message.content;
}
}
+8
View File
@@ -0,0 +1,8 @@
import { Module } from '@nestjs/common';
import { AIGatewayService } from './ai-gateway.service';
@Module({
providers: [AIGatewayService],
exports: [AIGatewayService],
})
export class AIModule {}
@@ -0,0 +1,131 @@
import { Test, TestingModule } from '@nestjs/testing';
import { AIGatewayService } from '../ai-gateway.service';
describe('AIGatewayService', () => {
let service: AIGatewayService;
beforeEach(async () => {
// Clear env before each test
delete process.env.DEEPSEEK_API_KEY;
delete process.env.DASHSCOPE_API_KEY;
const module: TestingModule = await Test.createTestingModule({
providers: [AIGatewayService],
}).compile();
service = module.get<AIGatewayService>(AIGatewayService);
});
describe('initialization', () => {
it('should have no providers when no API keys set', () => {
const providers = service.getRegisteredProviders();
expect(providers).toEqual([]);
});
});
describe('fallback mock responses', () => {
it('should greet when asked "你好"', async () => {
const result = await service.chat('any-model', [
{ role: 'user', content: '你好' },
]);
expect(result).toContain('你好!我是宇之然 AI 助手');
});
it('should greet in English for "hello"', async () => {
const result = await service.chat('any-model', [
{ role: 'user', content: 'hello' },
]);
expect(result).toContain('Hello! I am YuZhiRan AI assistant');
});
it('should provide prompt tips when asked about 提示词', async () => {
const result = await service.chat('any-model', [
{ role: 'user', content: '如何写好提示词?' },
]);
expect(result).toContain('明确角色');
expect(result).toContain('输出格式');
});
it('should provide model info when asked about 大模型', async () => {
const result = await service.chat('any-model', [
{ role: 'user', content: '有哪些大模型?' },
]);
expect(result).toContain('GPT');
expect(result).toContain('Claude');
expect(result).toContain('DeepSeek');
});
it('should return generic fallback for unknown queries', async () => {
const result = await service.chat('any-model', [
{ role: 'user', content: '今天的天气怎么样?' },
]);
expect(result).toContain('模拟模式');
expect(result).toContain('API Key');
});
});
describe('model routing', () => {
it('should route deepseek models to deepseek provider (but fallback to mock)', async () => {
const result = await service.chat('deepseek-chat', [
{ role: 'user', content: '你好' },
]);
// Falls back to mock since no API key
expect(result).toContain('宇之然 AI 助手');
});
it('should route qwen models to dashscope provider (but fallback to mock)', async () => {
const result = await service.chat('qwen-max', [
{ role: 'user', content: '你好' },
]);
expect(result).toContain('宇之然 AI 助手');
});
it('should handle unknown model names by falling back', async () => {
const result = await service.chat('unknown-model-xyz', [
{ role: 'user', content: '测试' },
]);
expect(result).toContain('模拟模式');
});
});
describe('contextual fallback', () => {
it('should include user message in fallback response', async () => {
const result = await service.chat('any', [
{ role: 'user', content: '如何学习 Python 编程?' },
]);
expect(result).toContain('Python');
});
it('should handle multi-turn conversations', async () => {
const messages = [
{ role: 'user' as const, content: '你是谁?' },
{ role: 'assistant' as const, content: '我是 AI 助手。' },
{ role: 'user' as const, content: '提示词有什么技巧?' },
];
const result = await service.chat('any', messages);
expect(result).toContain('提示词');
});
it('should truncate long user messages', async () => {
const longMsg = 'a'.repeat(200);
const result = await service.chat('any', [
{ role: 'user', content: longMsg },
]);
expect(result).toContain('...');
});
});
});
@@ -0,0 +1,33 @@
import { Controller, Post, Get, Body, UseGuards, Req } from '@nestjs/common';
import { ApiTags, ApiBearerAuth } from '@nestjs/swagger';
import { AuthGuard } from '@nestjs/passport';
import { AuthService } from './auth.service';
import { RegisterDto } from './dto/register.dto';
@ApiTags('认证')
@Controller('auth')
export class AuthController {
constructor(private authService: AuthService) {}
@Post('register')
async register(@Body() body: RegisterDto) {
return this.authService.register(body);
}
@Post('login')
async login(@Body() body: { account: string; password: string }) {
return this.authService.login(body.account, body.password);
}
@Post('refresh')
async refresh(@Body() body: { accessToken: string }) {
return this.authService.refreshAccessToken(body.accessToken);
}
@Get('profile')
@UseGuards(AuthGuard('jwt'))
@ApiBearerAuth()
async profile(@Req() req: any) {
return this.authService.getProfile(req.user.userId);
}
}
+24
View File
@@ -0,0 +1,24 @@
import { Module } from '@nestjs/common';
import { JwtModule } from '@nestjs/jwt';
import { PassportModule } from '@nestjs/passport';
import { ConfigService } from '@nestjs/config';
import { AuthController } from './auth.controller';
import { AuthService } from './auth.service';
import { JwtStrategy } from './jwt.strategy';
@Module({
imports: [
PassportModule.register({ defaultStrategy: 'jwt' }),
JwtModule.registerAsync({
useFactory: (config: ConfigService) => ({
secret: config.get('JWT_SECRET'),
signOptions: { expiresIn: config.get('JWT_EXPIRES_IN') || '2h' },
}),
inject: [ConfigService],
}),
],
controllers: [AuthController],
providers: [AuthService, JwtStrategy],
exports: [AuthService, JwtModule],
})
export class AuthModule {}
+91
View File
@@ -0,0 +1,91 @@
import { Injectable, UnauthorizedException } from '@nestjs/common';
import { JwtService } from '@nestjs/jwt';
import * as bcrypt from 'bcryptjs';
import { PrismaService } from '../../prisma/prisma.service';
@Injectable()
export class AuthService {
constructor(
private prisma: PrismaService,
private jwtService: JwtService,
) {}
async register(data: { phone?: string; email?: string; password: string; nickname?: string }) {
const passwordHash = await bcrypt.hash(data.password, 10);
const user = await this.prisma.user.create({
data: {
phone: data.phone,
email: data.email,
passwordHash,
nickname: data.nickname || data.phone || data.email?.split('@')[0],
},
});
return this.generateTokens(user.id);
}
async login(account: string, password: string) {
const user = await this.prisma.user.findFirst({
where: {
OR: [{ phone: account }, { email: account }],
deletedAt: null,
},
});
if (!user || !user.passwordHash) {
throw new UnauthorizedException('账号或密码错误');
}
const isValid = await bcrypt.compare(password, user.passwordHash);
if (!isValid) {
throw new UnauthorizedException('账号或密码错误');
}
await this.prisma.user.update({
where: { id: user.id },
data: { lastLoginAt: new Date() },
});
return this.generateTokens(user.id);
}
async refreshAccessToken(accessToken: string) {
try {
const payload = this.jwtService.verify(accessToken, { ignoreExpiration: true });
const user = await this.prisma.user.findUnique({
where: { id: payload.sub, deletedAt: null },
});
if (!user || user.status !== 'ACTIVE') {
throw new UnauthorizedException('用户不可用');
}
return this.generateTokens(user.id);
} catch {
throw new UnauthorizedException('Token 无效');
}
}
async getProfile(userId: number) {
return 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,
},
});
}
private generateTokens(userId: number) {
const payload = { sub: userId };
return {
accessToken: this.jwtService.sign(payload, { expiresIn: '2h' }),
refreshToken: this.jwtService.sign(payload, { expiresIn: '7d' }),
};
}
}
@@ -0,0 +1,20 @@
import { IsNotEmpty, IsOptional, IsString, MinLength } from 'class-validator';
export class RegisterDto {
@IsOptional()
@IsString()
phone?: string;
@IsOptional()
@IsString()
email?: string;
@IsNotEmpty({ message: '密码不能为空' })
@IsString()
@MinLength(6, { message: '密码至少6位' })
password: string;
@IsOptional()
@IsString()
nickname?: string;
}
+36
View File
@@ -0,0 +1,36 @@
import { Injectable, UnauthorizedException } from '@nestjs/common';
import { PassportStrategy } from '@nestjs/passport';
import { ExtractJwt, Strategy } from 'passport-jwt';
import { ConfigService } from '@nestjs/config';
import { PrismaService } from '../../prisma/prisma.service';
@Injectable()
export class JwtStrategy extends PassportStrategy(Strategy) {
constructor(
private config: ConfigService,
private prisma: PrismaService,
) {
const secret = config.get<string>('JWT_SECRET') || 'yuzhiran-ai-default-secret';
super({
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
ignoreExpiration: false,
secretOrKey: secret,
});
}
async validate(payload: { sub: number; type?: string }) {
if (payload.type === 'admin') {
const admin = await this.prisma.adminUser.findUnique({ where: { id: payload.sub } });
if (!admin || admin.status !== 'ACTIVE') {
throw new UnauthorizedException();
}
return { userId: payload.sub, isAdmin: true };
}
const user = await this.prisma.user.findUnique({ where: { id: payload.sub } });
if (!user || user.deletedAt) {
throw new UnauthorizedException();
}
return { userId: payload.sub, isAdmin: false };
}
}
@@ -0,0 +1,45 @@
// @ts-nocheck
import { Test, TestingModule } from '@nestjs/testing';
import { JwtService } from '@nestjs/jwt';
import { AuthService } from '../auth.service';
import { PrismaService } from '../../../prisma/prisma.service';
jest.mock('bcryptjs', () => ({
compare: jest.fn(),
hash: jest.fn(),
}));
describe('AuthService', () => {
let service: AuthService;
beforeEach(async () => {
const mockPrisma = {
user: {
create: jest.fn().mockResolvedValue({ id: 1 }),
findFirst: jest.fn().mockResolvedValue(null),
findUnique: jest.fn().mockResolvedValue(null),
update: jest.fn().mockResolvedValue({}),
},
};
const mockJwtService = {
sign: jest.fn().mockReturnValue('mock-token'),
verify: jest.fn().mockReturnValue({ sub: 1 }),
};
const module = await Test.createTestingModule({
providers: [
AuthService,
{ provide: PrismaService, useValue: mockPrisma },
{ provide: JwtService, useValue: mockJwtService },
],
}).compile();
service = module.get<AuthService>(AuthService);
jest.clearAllMocks();
});
it('should be defined', () => {
expect(service).toBeDefined();
});
});
@@ -0,0 +1,37 @@
import { Controller, Get, Post, Put, Delete, Body, Param } from '@nestjs/common';
import { ApiTags } from '@nestjs/swagger';
import { PrismaService } from '../../prisma/prisma.service';
@ApiTags('分类')
@Controller('categories')
export class CategoriesController {
constructor(private prisma: PrismaService) {}
@Get()
async findAll() {
return this.prisma.category.findMany({
orderBy: { sortOrder: 'asc' },
include: { _count: { select: { courses: true, prompts: true, tools: true, contents: true } } },
});
}
@Get(':id')
async findById(@Param('id') id: string) {
return this.prisma.category.findUnique({ where: { id: +id } });
}
@Post()
async create(@Body() body: { name: string; slug: string; description?: string; sortOrder?: number }) {
return this.prisma.category.create({ data: body });
}
@Put(':id')
async update(@Param('id') id: string, @Body() body: any) {
return this.prisma.category.update({ where: { id: +id }, data: body });
}
@Delete(':id')
async remove(@Param('id') id: string) {
return this.prisma.category.delete({ where: { id: +id } });
}
}
@@ -0,0 +1,7 @@
import { Module } from '@nestjs/common';
import { CategoriesController } from './categories.controller';
@Module({
controllers: [CategoriesController],
})
export class CategoriesModule {}
@@ -0,0 +1,60 @@
import { Controller, Get, Post, Body, Param, Query, UseGuards, Req } from '@nestjs/common';
import { ApiTags, ApiBearerAuth } from '@nestjs/swagger';
import { AuthGuard } from '@nestjs/passport';
import { CommunityService } from './community.service';
@ApiTags('圈子')
@Controller('circles')
export class CirclesController {
constructor(private communityService: CommunityService) {}
@Get()
async findCircles(@Query('category') category?: string) {
return this.communityService.findCircles(category);
}
@Get(':id')
async findCircleById(@Param('id') id: string) {
return this.communityService.findCircleById(+id);
}
@Get(':id/posts')
async findCirclePosts(
@Param('id') id: string,
@Query('page') page?: string,
@Query('pageSize') pageSize?: string,
) {
return this.communityService.findCirclePosts(+id, {
page: page ? parseInt(page) : undefined,
pageSize: pageSize ? parseInt(pageSize) : undefined,
});
}
@Post()
@UseGuards(AuthGuard('jwt'))
@ApiBearerAuth()
async createCircle(@Req() req: any, @Body() body: { name: string; description?: string; tags?: string }) {
return this.communityService.createCircle(req.user.userId, body);
}
@Post(':id/join')
@UseGuards(AuthGuard('jwt'))
@ApiBearerAuth()
async joinCircle(@Req() req: any, @Param('id') id: string) {
return this.communityService.joinCircle(req.user.userId, +id);
}
@Post(':id/leave')
@UseGuards(AuthGuard('jwt'))
@ApiBearerAuth()
async leaveCircle(@Req() req: any, @Param('id') id: string) {
return this.communityService.leaveCircle(req.user.userId, +id);
}
@Get(':id/membership')
@UseGuards(AuthGuard('jwt'))
@ApiBearerAuth()
async checkMembership(@Req() req: any, @Param('id') id: string) {
return this.communityService.checkCircleMembership(req.user.userId, +id);
}
}
@@ -0,0 +1,131 @@
import { Controller, Get, Post, Put, Delete, Body, Param, Query, UseGuards, Req } from '@nestjs/common';
import { ApiTags, ApiBearerAuth } from '@nestjs/swagger';
import { AuthGuard } from '@nestjs/passport';
import { CommunityService } from './community.service';
@ApiTags('社区')
@Controller('community')
export class CommunityController {
constructor(private communityService: CommunityService) {}
@Get('posts')
async findPosts(
@Query('page') page?: string,
@Query('pageSize') pageSize?: string,
@Query('tag') tag?: string,
@Query('circleId') circleId?: string,
@Query('userId') userId?: string,
) {
return this.communityService.findPosts({
page: page ? parseInt(page) : undefined,
pageSize: pageSize ? parseInt(pageSize) : undefined,
tag,
circleId: circleId ? parseInt(circleId) : undefined,
userId: userId ? parseInt(userId) : undefined,
});
}
@Get('posts/:id')
async findPostById(@Param('id') id: string) {
return this.communityService.findPostById(parseInt(id));
}
@Post('posts')
@UseGuards(AuthGuard('jwt'))
@ApiBearerAuth()
async createPost(@Req() req: any, @Body() body: { title: string; content: string; tags?: string; circleId?: number }) {
return this.communityService.createPost(req.user.userId, body);
}
@Put('posts/:id')
@UseGuards(AuthGuard('jwt'))
@ApiBearerAuth()
async updatePost(@Req() req: any, @Param('id') id: string, @Body() body: { title?: string; content?: string; tags?: string }) {
return this.communityService.updatePost(req.user.userId, parseInt(id), body);
}
@Delete('posts/:id')
@UseGuards(AuthGuard('jwt'))
@ApiBearerAuth()
async deletePost(@Req() req: any, @Param('id') id: string) {
return this.communityService.deletePost(req.user.userId, parseInt(id));
}
@Post('posts/:id/comments')
@UseGuards(AuthGuard('jwt'))
@ApiBearerAuth()
async addComment(
@Req() req: any,
@Param('id') id: string,
@Body('content') content: string,
@Body('parentId') parentId?: number,
) {
return this.communityService.addComment(req.user.userId, parseInt(id), content, parentId);
}
@Post('posts/:id/like')
@UseGuards(AuthGuard('jwt'))
@ApiBearerAuth()
async toggleLike(@Req() req: any, @Param('id') id: string) {
return this.communityService.toggleLike(req.user.userId, parseInt(id));
}
@Get('posts/:id/like')
@UseGuards(AuthGuard('jwt'))
@ApiBearerAuth()
async checkLike(@Req() req: any, @Param('id') id: string) {
return this.communityService.checkLike(req.user.userId, parseInt(id));
}
@Get('feed')
@UseGuards(AuthGuard('jwt'))
@ApiBearerAuth()
async getFeed(@Req() req: any, @Query('page') page?: string, @Query('pageSize') pageSize?: string) {
return this.communityService.getFeed(req.user.userId, {
page: page ? parseInt(page) : undefined,
pageSize: pageSize ? parseInt(pageSize) : undefined,
});
}
@Post('users/:id/follow')
@UseGuards(AuthGuard('jwt'))
@ApiBearerAuth()
async followUser(@Req() req: any, @Param('id') id: string) {
return this.communityService.followUser(req.user.userId, parseInt(id));
}
@Delete('users/:id/follow')
@UseGuards(AuthGuard('jwt'))
@ApiBearerAuth()
async unfollowUser(@Req() req: any, @Param('id') id: string) {
return this.communityService.unfollowUser(req.user.userId, parseInt(id));
}
@Get('users/:id/follow')
@UseGuards(AuthGuard('jwt'))
@ApiBearerAuth()
async checkFollow(@Req() req: any, @Param('id') id: string) {
return this.communityService.checkFollow(req.user.userId, parseInt(id));
}
@Get('users/:id/followers')
async getFollowers(@Param('id') id: string, @Query('page') page?: string, @Query('pageSize') pageSize?: string) {
return this.communityService.getFollowers(parseInt(id), {
page: page ? parseInt(page) : undefined,
pageSize: pageSize ? parseInt(pageSize) : undefined,
});
}
@Get('users/:id/following')
async getFollowing(@Param('id') id: string, @Query('page') page?: string, @Query('pageSize') pageSize?: string) {
return this.communityService.getFollowing(parseInt(id), {
page: page ? parseInt(page) : undefined,
pageSize: pageSize ? parseInt(pageSize) : undefined,
});
}
@Get('users/:id/profile')
async getUserProfile(@Param('id') id: string) {
return this.communityService.getUserProfile(parseInt(id));
}
}
@@ -0,0 +1,11 @@
import { Module } from '@nestjs/common';
import { CommunityController } from './community.controller';
import { CirclesController } from './circles.controller';
import { CommunityService } from './community.service';
import { NotificationService } from '../notifications/notification.service';
@Module({
controllers: [CommunityController, CirclesController],
providers: [CommunityService, NotificationService],
})
export class CommunityModule {}
@@ -0,0 +1,385 @@
import { Injectable, NotFoundException, ConflictException, BadRequestException, ForbiddenException } from '@nestjs/common';
import { PrismaService } from '../../prisma/prisma.service';
import { NotificationService } from '../notifications/notification.service';
const SENSITIVE_WORDS = ['敏感词1', '敏感词2', '广告', '诈骗', '违法', '赌博'];
function containsSensitiveWord(text: string): string | null {
const lower = text.toLowerCase();
for (const word of SENSITIVE_WORDS) {
if (lower.includes(word)) return word;
}
return null;
}
@Injectable()
export class CommunityService {
constructor(
private prisma: PrismaService,
private notificationService: NotificationService,
) {}
async findPosts(params: { page?: number; pageSize?: number; tag?: string; circleId?: number; userId?: number }) {
const page = Number(params.page ?? 1);
const pageSize = Math.min(Number(params.pageSize ?? 20), 50);
const where: any = { status: 'PUBLISHED' };
if (params.tag) where.tags = { contains: params.tag };
if (params.circleId) where.circleId = params.circleId;
if (params.userId) where.userId = params.userId;
const [items, total] = await Promise.all([
this.prisma.post.findMany({
where,
skip: (page - 1) * pageSize,
take: pageSize,
orderBy: { createdAt: 'desc' },
include: { user: { select: { id: true, nickname: true, avatar: true } } },
}),
this.prisma.post.count({ where }),
]);
return { items, total, page, pageSize };
}
async findPostById(id: number) {
const post = await this.prisma.post.findUnique({
where: { id },
include: {
user: { select: { id: true, nickname: true, avatar: true } },
comments: {
where: { status: 'PUBLISHED' },
include: {
user: { select: { id: true, nickname: true, avatar: true } },
replies: {
where: { status: 'PUBLISHED' },
include: { user: { select: { id: true, nickname: true, avatar: true } } },
},
},
},
},
});
if (post) {
await this.prisma.post.update({
where: { id },
data: { viewCount: { increment: 1 } },
});
}
return post;
}
async createPost(userId: number, data: { title: string; content: string; tags?: string; circleId?: number }) {
const post = await this.prisma.post.create({
data: {
userId,
title: data.title,
content: data.content,
tags: data.tags,
circleId: data.circleId,
},
include: { user: { select: { id: true, nickname: true, avatar: true } } },
});
await this.prisma.user.update({
where: { id: userId },
data: { postCount: { increment: 1 } },
});
return post;
}
async updatePost(userId: number, postId: number, data: { title?: string; content?: string; tags?: string }) {
const post = await this.prisma.post.findUnique({ where: { id: postId } });
if (!post) throw new NotFoundException('帖子不存在');
if (post.userId !== userId) throw new BadRequestException('无权编辑此帖子');
return this.prisma.post.update({
where: { id: postId },
data,
include: { user: { select: { id: true, nickname: true, avatar: true } } },
});
}
async deletePost(userId: number, postId: number) {
const post = await this.prisma.post.findUnique({ where: { id: postId } });
if (!post) throw new NotFoundException('帖子不存在');
if (post.userId !== userId) throw new BadRequestException('无权删除此帖子');
await this.prisma.post.delete({ where: { id: postId } });
await this.prisma.user.update({
where: { id: userId },
data: { postCount: { decrement: 1 } },
});
return { deleted: true };
}
async addComment(userId: number, postId: number, content: string, parentId?: number) {
const post = await this.prisma.post.findUnique({ where: { id: postId } });
if (!post) throw new NotFoundException('帖子不存在');
if (parentId) {
const parentComment = await this.prisma.comment.findUnique({ where: { id: parentId } });
if (!parentComment || parentComment.postId !== postId) {
throw new BadRequestException('父评论不存在');
}
}
const matchedWord = containsSensitiveWord(content);
const status = matchedWord ? 'REJECTED' : 'PENDING_REVIEW';
const comment = await this.prisma.comment.create({
data: { userId, postId, content, parentId, status },
include: { user: { select: { id: true, nickname: true, avatar: true } } },
});
if (matchedWord) {
return { ...comment, rejected: true, reason: `评论包含敏感词「${matchedWord}` };
}
await this.prisma.post.update({
where: { id: postId },
data: { commentCount: { increment: 1 } },
});
if (post.userId !== userId) {
await this.notificationService.create({
userId: post.userId,
type: 'comment',
title: `${comment.user.nickname || '用户'} 评论了你的帖子`,
content: content.slice(0, 100),
link: `/community/${postId}`,
relatedId: postId,
});
}
return comment;
}
async toggleLike(userId: number, postId: number) {
const existing = await this.prisma.postLike.findFirst({ where: { userId, postId } });
if (existing) {
await this.prisma.postLike.delete({ where: { id: existing.id } });
await this.prisma.post.update({
where: { id: postId },
data: { likeCount: { decrement: 1 } },
});
return { liked: false };
} else {
await this.prisma.postLike.create({ data: { userId, postId } });
await this.prisma.post.update({
where: { id: postId },
data: { likeCount: { increment: 1 } },
});
const post = await this.prisma.post.findUnique({ where: { id: postId }, select: { userId: true, title: true } });
if (post && post.userId !== userId) {
await this.notificationService.create({
userId: post.userId,
type: 'like',
title: `有人赞了你的帖子「${post.title.slice(0, 30)}`,
link: `/community/${postId}`,
relatedId: postId,
});
}
return { liked: true };
}
}
async checkLike(userId: number, postId: number) {
const existing = await this.prisma.postLike.findFirst({ where: { userId, postId } });
return { liked: !!existing };
}
async getFeed(userId: number, params: { page?: number; pageSize?: number }) {
const page = Number(params.page ?? 1);
const pageSize = Math.min(Number(params.pageSize ?? 20), 50);
const following = await this.prisma.follow.findMany({
where: { followerId: userId },
select: { followingId: true },
});
const followingIds = following.map(f => f.followingId);
const where: any = { status: 'PUBLISHED' };
if (followingIds.length > 0) {
where.userId = { in: followingIds };
}
const [items, total] = await Promise.all([
this.prisma.post.findMany({
where,
skip: (page - 1) * pageSize,
take: pageSize,
orderBy: { createdAt: 'desc' },
include: { user: { select: { id: true, nickname: true, avatar: true } } },
}),
this.prisma.post.count({ where }),
]);
return { items, total, page, pageSize };
}
async followUser(followerId: number, followingId: number) {
if (followerId === followingId) throw new BadRequestException('不能关注自己');
const existing = await this.prisma.follow.findUnique({
where: { followerId_followingId: { followerId, followingId } },
});
if (existing) throw new ConflictException('已关注该用户');
await this.prisma.follow.create({ data: { followerId, followingId } });
await this.prisma.user.update({ where: { id: followerId }, data: { followingCount: { increment: 1 } } });
await this.prisma.user.update({ where: { id: followingId }, data: { followerCount: { increment: 1 } } });
const follower = await this.prisma.user.findUnique({ where: { id: followerId }, select: { nickname: true } });
await this.notificationService.create({
userId: followingId,
type: 'follow',
title: `${follower?.nickname || '用户'} 关注了你`,
link: `/users/${followerId}`,
relatedId: followerId,
});
return { followed: true };
}
async unfollowUser(followerId: number, followingId: number) {
const existing = await this.prisma.follow.findUnique({
where: { followerId_followingId: { followerId, followingId } },
});
if (!existing) throw new NotFoundException('未关注该用户');
await this.prisma.follow.delete({ where: { id: existing.id } });
await this.prisma.user.update({ where: { id: followerId }, data: { followingCount: { decrement: 1 } } });
await this.prisma.user.update({ where: { id: followingId }, data: { followerCount: { decrement: 1 } } });
return { followed: false };
}
async checkFollow(followerId: number, followingId: number) {
const existing = await this.prisma.follow.findUnique({
where: { followerId_followingId: { followerId, followingId } },
});
return { followed: !!existing };
}
async getFollowers(userId: number, params: { page?: number; pageSize?: number }) {
const page = Number(params.page ?? 1);
const pageSize = Math.min(Number(params.pageSize ?? 20), 50);
const [items, total] = await Promise.all([
this.prisma.follow.findMany({
where: { followingId: userId },
skip: (page - 1) * pageSize,
take: pageSize,
include: { follower: { select: { id: true, nickname: true, avatar: true, followerCount: true, followingCount: true } } },
orderBy: { createdAt: 'desc' },
}),
this.prisma.follow.count({ where: { followingId: userId } }),
]);
return { items: items.map(i => i.follower), total, page, pageSize };
}
async getFollowing(userId: number, params: { page?: number; pageSize?: number }) {
const page = Number(params.page ?? 1);
const pageSize = Math.min(Number(params.pageSize ?? 20), 50);
const [items, total] = await Promise.all([
this.prisma.follow.findMany({
where: { followerId: userId },
skip: (page - 1) * pageSize,
take: pageSize,
include: { following: { select: { id: true, nickname: true, avatar: true, followerCount: true, followingCount: true } } },
orderBy: { createdAt: 'desc' },
}),
this.prisma.follow.count({ where: { followerId: userId } }),
]);
return { items: items.map(i => i.following), total, page, pageSize };
}
async getUserProfile(userId: number) {
const user = await this.prisma.user.findUnique({
where: { id: userId, deletedAt: null },
select: {
id: true, nickname: true, avatar: true, bio: true,
followerCount: true, followingCount: true, postCount: true,
createdAt: true,
},
});
if (!user) throw new NotFoundException('用户不存在');
return user;
}
async findCircles(category?: string) {
const where: any = {};
if (category) where.tags = { contains: category };
return this.prisma.circle.findMany({
where,
include: { _count: { select: { members: true, posts: true } } },
orderBy: { createdAt: 'desc' },
});
}
async findCircleById(id: number) {
const circle = await this.prisma.circle.findUnique({
where: { id },
include: {
_count: { select: { members: true, posts: true } },
creator: { select: { id: true, nickname: true, avatar: true } },
},
});
if (!circle) throw new NotFoundException('圈子不存在');
return circle;
}
async findCirclePosts(circleId: number, params: { page?: number; pageSize?: number }) {
return this.findPosts({ ...params, circleId });
}
async createCircle(userId: number, data: { name: string; description?: string; tags?: string }) {
const existing = await this.prisma.circle.findFirst({ where: { name: data.name } });
if (existing) throw new ConflictException('圈子名称已存在');
return this.prisma.circle.create({
data: { name: data.name, description: data.description, tags: data.tags, creatorId: userId },
include: { _count: { select: { members: true } } },
});
}
async joinCircle(userId: number, circleId: number) {
const circle = await this.prisma.circle.findUnique({ where: { id: circleId } });
if (!circle) throw new NotFoundException('圈子不存在');
const existing = await this.prisma.circleMember.findUnique({
where: { circleId_userId: { circleId, userId } },
});
if (existing) throw new ConflictException('已是圈子成员');
return this.prisma.circleMember.create({ data: { circleId, userId } });
}
async leaveCircle(userId: number, circleId: number) {
const existing = await this.prisma.circleMember.findUnique({
where: { circleId_userId: { circleId, userId } },
});
if (!existing) throw new BadRequestException('不是圈子成员');
await this.prisma.circleMember.delete({ where: { id: existing.id } });
return { left: true };
}
async checkCircleMembership(userId: number, circleId: number) {
const existing = await this.prisma.circleMember.findUnique({
where: { circleId_userId: { circleId, userId } },
});
return { isMember: !!existing };
}
}
@@ -0,0 +1,223 @@
import { Test, TestingModule } from '@nestjs/testing';
import { NotFoundException, ConflictException, BadRequestException } from '@nestjs/common';
import { CommunityService } from '../community.service';
import { PrismaService } from '../../../prisma/prisma.service';
import { NotificationService } from '../../notifications/notification.service';
const selectUser = { id: true, nickname: true, avatar: true };
describe('CommunityService', () => {
let service: CommunityService;
let prisma: PrismaService;
const mockPrisma = {
post: {
findMany: jest.fn(),
findUnique: jest.fn(),
create: jest.fn(),
update: jest.fn(),
delete: jest.fn(),
count: jest.fn(),
},
comment: {
create: jest.fn(),
},
postLike: {
findFirst: jest.fn(),
create: jest.fn(),
delete: jest.fn(),
},
user: {
findUnique: jest.fn(),
update: jest.fn(),
},
follow: {
findUnique: jest.fn(),
findMany: jest.fn(),
create: jest.fn(),
delete: jest.fn(),
count: jest.fn(),
},
circle: {
findMany: jest.fn(),
findUnique: jest.fn(),
findFirst: jest.fn(),
create: jest.fn(),
},
circleMember: {
findUnique: jest.fn(),
create: jest.fn(),
delete: jest.fn(),
},
};
const mockNotification = { create: jest.fn() };
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [
CommunityService,
{ provide: PrismaService, useValue: mockPrisma },
{ provide: NotificationService, useValue: mockNotification },
],
}).compile();
service = module.get<CommunityService>(CommunityService);
prisma = module.get<PrismaService>(PrismaService);
jest.clearAllMocks();
});
describe('findPosts', () => {
it('should return paginated posts without tag filter', async () => {
const mockPosts = [
{ id: 1, title: '帖子1', status: 'PUBLISHED' },
];
mockPrisma.post.findMany.mockResolvedValue(mockPosts);
mockPrisma.post.count.mockResolvedValue(1);
const result = await service.findPosts({});
expect(result).toEqual({ items: mockPosts, total: 1, page: 1, pageSize: 20 });
expect(mockPrisma.post.findMany).toHaveBeenCalledWith(
expect.objectContaining({
where: { status: 'PUBLISHED' },
skip: 0,
take: 20,
include: { user: { select: selectUser } },
})
);
});
it('should filter by tag', async () => {
await service.findPosts({ tag: 'AI' });
expect(mockPrisma.post.findMany).toHaveBeenCalledWith(
expect.objectContaining({
where: expect.objectContaining({ tags: { contains: 'AI' } }),
})
);
});
});
describe('findPostById', () => {
it('should increment viewCount and return post', async () => {
const mockPost = {
id: 1,
title: '测试帖子',
status: 'PUBLISHED',
user: { id: 1, nickname: '用户1' },
comments: [],
};
mockPrisma.post.update.mockResolvedValue(mockPost);
mockPrisma.post.findUnique.mockResolvedValue(mockPost);
const result = await service.findPostById(1);
expect(result).toEqual(mockPost);
expect(mockPrisma.post.update).toHaveBeenCalledWith({
where: { id: 1 },
data: { viewCount: { increment: 1 } },
});
});
});
describe('createPost', () => {
it('should create a new post', async () => {
const mockPost = {
id: 1,
title: '新帖子',
content: '内容',
userId: 1,
};
mockPrisma.post.create.mockResolvedValue(mockPost);
const result = await service.createPost(1, {
title: '新帖子',
content: '内容',
});
expect(result).toEqual(mockPost);
expect(mockPrisma.post.create).toHaveBeenCalledWith({
data: {
userId: 1,
title: '新帖子',
content: '内容',
},
include: { user: { select: selectUser } },
});
expect(mockPrisma.user.update).toHaveBeenCalledWith({
where: { id: 1 },
data: { postCount: { increment: 1 } },
});
});
});
describe('addComment', () => {
it('should add comment and increment commentCount', async () => {
const mockPost = { id: 1, userId: 2 };
mockPrisma.post.findUnique.mockResolvedValue(mockPost);
const mockComment = { id: 1, content: '评论', userId: 1, postId: 1, user: { id: 1, nickname: '用户1', avatar: null } };
mockPrisma.comment.create.mockResolvedValue(mockComment);
const result = await service.addComment(1, 1, '评论');
expect(result).toEqual(mockComment);
expect(mockPrisma.comment.create).toHaveBeenCalledWith({
data: { userId: 1, postId: 1, content: '评论', status: 'PENDING_REVIEW' },
include: { user: { select: selectUser } },
});
});
it('should throw NotFoundException if post not found', async () => {
mockPrisma.post.findUnique.mockResolvedValue(null);
await expect(service.addComment(1, 999, '评论')).rejects.toThrow(NotFoundException);
});
});
describe('toggleLike', () => {
it('should add like if not exists', async () => {
mockPrisma.postLike.findFirst.mockResolvedValue(null);
mockPrisma.postLike.create.mockResolvedValue({});
mockPrisma.post.findUnique.mockResolvedValue({ id: 1, userId: 2, title: '帖子' });
const result = await service.toggleLike(1, 1);
expect(result).toEqual({ liked: true });
expect(mockPrisma.postLike.create).toHaveBeenCalledWith({
data: { userId: 1, postId: 1 },
});
});
it('should remove like if exists', async () => {
mockPrisma.postLike.findFirst.mockResolvedValue({ id: 1 });
mockPrisma.postLike.delete.mockResolvedValue({});
const result = await service.toggleLike(1, 1);
expect(result).toEqual({ liked: false });
expect(mockPrisma.postLike.delete).toHaveBeenCalledWith({
where: { id: 1 },
});
});
});
describe('checkLike', () => {
it('should return liked status', async () => {
mockPrisma.postLike.findFirst.mockResolvedValue({ id: 1 });
const result = await service.checkLike(1, 1);
expect(result).toEqual({ liked: true });
});
it('should return not liked if no record', async () => {
mockPrisma.postLike.findFirst.mockResolvedValue(null);
const result = await service.checkLike(1, 1);
expect(result).toEqual({ liked: false });
});
});
});
@@ -0,0 +1,34 @@
import { Controller, Get, Post, Put, Delete, Body, Param, Query } from '@nestjs/common';
import { ApiTags } from '@nestjs/swagger';
import { ContentsService } from './contents.service';
@ApiTags('内容')
@Controller('contents')
export class ContentsController {
constructor(private contentsService: ContentsService) {}
@Get()
async findAll(@Query() query: { page?: number; pageSize?: number; categoryId?: number; contentType?: string }) {
return this.contentsService.findAll(query);
}
@Get(':id')
async findById(@Param('id') id: string) {
return this.contentsService.findById(+id);
}
@Post()
async create(@Body() body: any) {
return this.contentsService.create(body);
}
@Put(':id')
async update(@Param('id') id: string, @Body() body: any) {
return this.contentsService.update(+id, body);
}
@Delete(':id')
async remove(@Param('id') id: string) {
return this.contentsService.remove(+id);
}
}
@@ -0,0 +1,10 @@
import { Module } from '@nestjs/common';
import { ContentsController } from './contents.controller';
import { ContentsService } from './contents.service';
@Module({
controllers: [ContentsController],
providers: [ContentsService],
exports: [ContentsService],
})
export class ContentsModule {}
@@ -0,0 +1,52 @@
import { Injectable } from '@nestjs/common';
import { PrismaService } from '../../prisma/prisma.service';
@Injectable()
export class ContentsService {
constructor(private prisma: PrismaService) {}
async findAll(params: { page?: number; pageSize?: number; categoryId?: number; contentType?: string }) {
const page = Number(params.page ?? 1);
const pageSize = Number(params.pageSize ?? 20);
const { categoryId, contentType } = params;
const where: any = { status: 'PUBLISHED', deletedAt: null };
if (categoryId) where.categoryId = categoryId;
if (contentType) where.contentType = contentType;
const [items, total] = await Promise.all([
this.prisma.content.findMany({
where,
skip: (page - 1) * pageSize,
take: pageSize,
orderBy: { publishedAt: 'desc' },
select: {
id: true, title: true, summary: true, cover: true, contentType: true,
tags: true, authorName: true, viewCount: true, isAiGenerated: true,
publishedAt: true, createdAt: true, category: true,
},
}),
this.prisma.content.count({ where }),
]);
return { items, total, page, pageSize };
}
async findById(id: number) {
const content = await this.prisma.content.findUnique({ where: { id }, include: { category: true } });
if (!content) return null;
await this.prisma.content.update({ where: { id }, data: { viewCount: { increment: 1 } } });
return content;
}
async create(data: any) {
return this.prisma.content.create({ data });
}
async update(id: number, data: any) {
return this.prisma.content.update({ where: { id }, data });
}
async remove(id: number) {
return this.prisma.content.update({ where: { id }, data: { deletedAt: new Date() } });
}
}
@@ -0,0 +1,66 @@
import { Controller, Get, Post, Put, Delete, Body, Param, Query, UseGuards, Req } from '@nestjs/common';
import { ApiTags, ApiBearerAuth } from '@nestjs/swagger';
import { AuthGuard } from '@nestjs/passport';
import { CoursesService } from './courses.service';
@ApiTags('课程')
@Controller('courses')
export class CoursesController {
constructor(private coursesService: CoursesService) {}
@Get()
async findAll(@Query() query: { page?: number; pageSize?: number; categoryId?: number; isFree?: boolean }) {
return this.coursesService.findAll(query);
}
@Get(':id')
async findById(@Param('id') id: string) {
return this.coursesService.findById(+id);
}
@Post()
async create(@Body() body: {
title: string; description?: string; cover?: string; categoryId?: number;
price?: number; isFree?: boolean;
}) {
return this.coursesService.create(body);
}
@Put(':id')
async update(@Param('id') id: string, @Body() body: any) {
return this.coursesService.update(+id, body);
}
@Delete(':id')
async remove(@Param('id') id: string) {
return this.coursesService.remove(+id);
}
@Post(':courseId/lessons/:lessonId/progress')
@UseGuards(AuthGuard('jwt'))
@ApiBearerAuth()
async updateProgress(
@Req() req: any,
@Param('courseId') courseId: string,
@Param('lessonId') lessonId: string,
@Body() body: { completed?: boolean; progress?: number },
) {
return this.coursesService.updateProgress(
req.user.userId,
+courseId,
+lessonId,
body.completed,
body.progress,
);
}
@Get('my-learning')
@UseGuards(AuthGuard('jwt'))
@ApiBearerAuth()
async getMyLearning(@Req() req: any, @Query('courseId') courseId?: string) {
return this.coursesService.getLearningProgress(
req.user.userId,
courseId ? +courseId : undefined,
);
}
}
@@ -0,0 +1,10 @@
import { Module } from '@nestjs/common';
import { CoursesController } from './courses.controller';
import { CoursesService } from './courses.service';
@Module({
controllers: [CoursesController],
providers: [CoursesService],
exports: [CoursesService],
})
export class CoursesModule {}
@@ -0,0 +1,180 @@
import { Injectable } from '@nestjs/common';
import { PrismaService } from '../../prisma/prisma.service';
@Injectable()
export class CoursesService {
constructor(private prisma: PrismaService) {}
async findAll(params: { page?: number; pageSize?: number; categoryId?: number; isFree?: boolean }) {
const page = Number(params.page ?? 1);
const pageSize = Number(params.pageSize ?? 20);
const { categoryId, isFree } = params;
const where: any = { status: 'PUBLISHED', deletedAt: null };
if (categoryId) where.categoryId = categoryId;
if (isFree !== undefined) where.isFree = isFree;
const [items, total] = await Promise.all([
this.prisma.course.findMany({
where,
skip: (page - 1) * pageSize,
take: pageSize,
orderBy: { sortOrder: 'asc' },
include: { category: true, chapters: { include: { lessons: true }, orderBy: { sortOrder: 'asc' } } },
}),
this.prisma.course.count({ where }),
]);
return { items, total, page, pageSize };
}
async findById(id: number) {
return this.prisma.course.findUnique({
where: { id },
include: {
category: true,
chapters: {
orderBy: { sortOrder: 'asc' },
include: { lessons: { orderBy: { sortOrder: 'asc' } } },
},
},
});
}
async create(data: {
title: string; description?: string; cover?: string; categoryId?: number;
price?: number; isFree?: boolean; sortOrder?: number;
}) {
return this.prisma.course.create({ data });
}
async update(id: number, data: any) {
const { chapters, ...courseData } = data;
return this.prisma.course.update({
where: { id },
data: courseData,
});
}
async updateWithChapters(id: number, data: any) {
const { chapters, ...courseData } = data;
if (chapters) {
const existingChapters = await this.prisma.chapter.findMany({ where: { courseId: id } });
const existingIds = existingChapters.map(c => c.id);
const incomingIds = chapters.filter((c: any) => c.id).map((c: any) => c.id);
const toDelete = existingIds.filter(eid => !incomingIds.includes(eid));
for (const cid of toDelete) {
await this.prisma.lesson.deleteMany({ where: { chapterId: cid } });
await this.prisma.chapter.delete({ where: { id: cid } });
}
for (const ch of chapters) {
if (ch.id) {
const { lessons, id: chId, courseId, ...chData } = ch;
await this.prisma.chapter.update({ where: { id: ch.id }, data: chData });
if (lessons) {
const existingLessons = await this.prisma.lesson.findMany({ where: { chapterId: ch.id } });
const existingLessonIds = existingLessons.map(l => l.id);
const incomingLessonIds = lessons.filter((l: any) => l.id).map((l: any) => l.id);
const lessonsToDelete = existingLessonIds.filter(eid => !incomingLessonIds.includes(eid));
for (const lid of lessonsToDelete) {
await this.prisma.lesson.delete({ where: { id: lid } });
}
for (const le of lessons) {
if (le.id) {
const { id: lessonId, chapterId, ...lessonData } = le;
await this.prisma.lesson.update({ where: { id: le.id }, data: lessonData });
} else {
const { id: _li, ...lessonData } = le;
await this.prisma.lesson.create({ data: { ...lessonData, chapterId: ch.id } });
}
}
}
} else {
const { lessons, id: _newChId, ...chData } = ch;
const newCh = await this.prisma.chapter.create({ data: { ...chData, courseId: id } });
if (lessons) {
for (const le of lessons) {
const { id: _li, ...lessonData } = le;
await this.prisma.lesson.create({ data: { ...lessonData, chapterId: newCh.id } });
}
}
}
}
}
return this.prisma.course.update({
where: { id },
data: courseData,
include: { chapters: { include: { lessons: { orderBy: { sortOrder: 'asc' } } }, orderBy: { sortOrder: 'asc' } } },
});
}
async remove(id: number) {
return this.prisma.course.update({ where: { id }, data: { deletedAt: new Date() } });
}
async updateProgress(userId: number, courseId: number, lessonId: number, completed?: boolean, progress?: number) {
const existing = await this.prisma.learnRecord.findUnique({
where: { userId_lessonId: { userId, lessonId } },
});
if (existing) {
return this.prisma.learnRecord.update({
where: { id: existing.id },
data: {
completed: completed ?? existing.completed,
progress: progress ?? existing.progress,
},
});
} else {
return this.prisma.learnRecord.create({
data: {
userId,
courseId,
lessonId,
completed: completed ?? false,
progress: progress ?? 0,
},
});
}
}
async getLearningProgress(userId: number, courseId?: number) {
const where: any = { userId };
if (courseId) {
where.courseId = courseId;
}
const records = await this.prisma.learnRecord.findMany({
where,
include: {
course: { select: { id: true, title: true } },
lesson: { select: { id: true, title: true, chapterId: true } },
},
});
const courseMap: Record<number, any> = {};
records.forEach(r => {
if (!courseMap[r.courseId]) {
courseMap[r.courseId] = {
courseId: r.courseId,
courseTitle: r.course.title,
totalLessons: 0,
completedLessons: 0,
progress: 0,
records: [],
};
}
courseMap[r.courseId].records.push(r);
courseMap[r.courseId].totalLessons++;
if (r.completed) courseMap[r.courseId].completedLessons++;
});
Object.values(courseMap).forEach(c => {
c.progress = c.totalLessons > 0 ? Math.round((c.completedLessons / c.totalLessons) * 100) : 0;
});
return {
items: Object.values(courseMap),
total: records.length,
};
}
}
@@ -0,0 +1,279 @@
import { Test, TestingModule } from '@nestjs/testing';
import { CoursesService } from '../courses.service';
import { PrismaService } from '../../../prisma/prisma.service';
describe('CoursesService', () => {
let service: CoursesService;
let prisma: PrismaService;
const mockPrisma = {
course: {
findMany: jest.fn(),
findUnique: jest.fn(),
create: jest.fn(),
update: jest.fn(),
count: jest.fn(),
},
chapter: {
findMany: jest.fn(),
create: jest.fn(),
update: jest.fn(),
delete: jest.fn(),
deleteMany: jest.fn(),
},
lesson: {
findMany: jest.fn(),
create: jest.fn(),
update: jest.fn(),
delete: jest.fn(),
deleteMany: jest.fn(),
},
};
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [
CoursesService,
{ provide: PrismaService, useValue: mockPrisma },
],
}).compile();
service = module.get<CoursesService>(CoursesService);
prisma = module.get<PrismaService>(PrismaService);
jest.clearAllMocks();
});
describe('findAll', () => {
it('should return paginated courses with chapters/lessons', async () => {
const mockCourses = [
{ id: 1, title: 'AI 入门', status: 'PUBLISHED', chapters: [{ id: 1, lessons: [{ id: 1 }] }] },
];
mockPrisma.course.findMany.mockResolvedValue(mockCourses);
mockPrisma.course.count.mockResolvedValue(1);
const result = await service.findAll({ page: 1, pageSize: 20 });
expect(result).toEqual({ items: mockCourses, total: 1, page: 1, pageSize: 20 });
expect(mockPrisma.course.findMany).toHaveBeenCalledWith(
expect.objectContaining({
where: { status: 'PUBLISHED', deletedAt: null },
skip: 0,
take: 20,
})
);
});
it('should filter by categoryId', async () => {
mockPrisma.course.findMany.mockResolvedValue([]);
mockPrisma.course.count.mockResolvedValue(0);
await service.findAll({ categoryId: 3 });
expect(mockPrisma.course.findMany).toHaveBeenCalledWith(
expect.objectContaining({
where: expect.objectContaining({ categoryId: 3 }),
})
);
});
it('should filter free courses', async () => {
mockPrisma.course.findMany.mockResolvedValue([]);
mockPrisma.course.count.mockResolvedValue(0);
await service.findAll({ isFree: true });
expect(mockPrisma.course.findMany).toHaveBeenCalledWith(
expect.objectContaining({
where: expect.objectContaining({ isFree: true }),
})
);
});
it('should apply pagination correctly', async () => {
mockPrisma.course.findMany.mockResolvedValue([]);
mockPrisma.course.count.mockResolvedValue(50);
const result = await service.findAll({ page: 3, pageSize: 10 });
expect(result.page).toBe(3);
expect(result.pageSize).toBe(10);
expect(mockPrisma.course.findMany).toHaveBeenCalledWith(
expect.objectContaining({ skip: 20, take: 10 })
);
});
});
describe('findById', () => {
it('should return course with chapters and lessons', async () => {
const mockCourse = {
id: 1,
title: 'AI 入门',
chapters: [{ id: 1, title: '第一章', lessons: [{ id: 1, title: '第一课' }] }],
};
mockPrisma.course.findUnique.mockResolvedValue(mockCourse);
const result = await service.findById(1);
expect(result).toEqual(mockCourse);
expect(mockPrisma.course.findUnique).toHaveBeenCalledWith({
where: { id: 1 },
include: expect.objectContaining({
chapters: expect.objectContaining({
include: { lessons: expect.any(Object) },
}),
}),
});
});
it('should return null for non-existent course', async () => {
mockPrisma.course.findUnique.mockResolvedValue(null);
const result = await service.findById(999);
expect(result).toBeNull();
});
});
describe('create', () => {
it('should create a new course', async () => {
const newCourse = { id: 2, title: '新课程', isFree: true };
mockPrisma.course.create.mockResolvedValue(newCourse);
const result = await service.create({ title: '新课程', isFree: true });
expect(result).toEqual(newCourse);
expect(mockPrisma.course.create).toHaveBeenCalledWith({
data: { title: '新课程', isFree: true },
});
});
it('should create course with all fields', async () => {
mockPrisma.course.create.mockResolvedValue({ id: 3 });
await service.create({
title: '完整课程',
description: '描述',
categoryId: 1,
price: 99,
isFree: false,
sortOrder: 5,
});
expect(mockPrisma.course.create).toHaveBeenCalledWith({
data: {
title: '完整课程',
description: '描述',
categoryId: 1,
price: 99,
isFree: false,
sortOrder: 5,
},
});
});
});
describe('update', () => {
it('should update course fields', async () => {
mockPrisma.course.update.mockResolvedValue({ id: 1, title: '更新标题' });
const result = await service.update(1, { title: '更新标题' });
expect(result).toEqual({ id: 1, title: '更新标题' });
expect(mockPrisma.course.update).toHaveBeenCalledWith({
where: { id: 1 },
data: { title: '更新标题' },
});
});
it('should strip chapters from course data update', async () => {
mockPrisma.course.update.mockResolvedValue({ id: 1 });
await service.update(1, { title: 'test', chapters: [{ title: 'ch1' }] });
expect(mockPrisma.course.update).toHaveBeenCalledWith({
where: { id: 1 },
data: { title: 'test' }, // chapters stripped
});
});
});
describe('remove', () => {
it('should soft-delete a course', async () => {
mockPrisma.course.update.mockResolvedValue({ id: 1, deletedAt: new Date() });
const result = await service.remove(1);
expect(result).toBeDefined();
expect(mockPrisma.course.update).toHaveBeenCalledWith({
where: { id: 1 },
data: { deletedAt: expect.any(Date) },
});
});
});
describe('updateWithChapters', () => {
beforeEach(() => {
mockPrisma.chapter.findMany.mockResolvedValue([]);
mockPrisma.course.update.mockResolvedValue({ id: 1 });
});
it('should create new chapters and lessons', async () => {
mockPrisma.chapter.findMany.mockResolvedValue([]);
mockPrisma.chapter.create.mockResolvedValue({ id: 10 });
mockPrisma.lesson.create.mockResolvedValue({ id: 20 });
const result = await service.updateWithChapters(1, {
title: '更新课程',
chapters: [
{
title: '新章节',
sortOrder: 1,
lessons: [{ title: '新课时', sortOrder: 1, status: 'PUBLISHED' }],
},
],
});
expect(result).toBeDefined();
expect(mockPrisma.chapter.create).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.objectContaining({
title: '新章节',
courseId: 1,
}),
})
);
});
it('should delete removed chapters', async () => {
mockPrisma.chapter.findMany.mockResolvedValue([
{ id: 5, courseId: 1, title: '旧章节', sortOrder: 1 },
]);
mockPrisma.lesson.deleteMany.mockResolvedValue({ count: 0 });
mockPrisma.chapter.delete.mockResolvedValue({ id: 5 });
await service.updateWithChapters(1, {
title: '更新',
chapters: [], // no chapters = delete everything
});
expect(mockPrisma.chapter.delete).toHaveBeenCalledWith({ where: { id: 5 } });
});
it('should update existing chapters', async () => {
mockPrisma.chapter.findMany.mockResolvedValue([
{ id: 5, courseId: 1, title: '旧章节', sortOrder: 1 },
]);
mockPrisma.lesson.findMany.mockResolvedValue([]);
mockPrisma.chapter.update.mockResolvedValue({ id: 5 });
await service.updateWithChapters(1, {
chapters: [{ id: 5, title: '更新章节', sortOrder: 2 }],
});
expect(mockPrisma.chapter.update).toHaveBeenCalledWith({
where: { id: 5 },
data: { title: '更新章节', sortOrder: 2 },
});
});
});
});
@@ -0,0 +1,37 @@
import { Controller, Get, Put, Body, UseGuards, Req } from '@nestjs/common';
import { ApiTags, ApiBearerAuth } from '@nestjs/swagger';
import { AuthGuard } from '@nestjs/passport';
import { DashboardService } from './dashboard.service';
@ApiTags('仪表盘')
@Controller('dashboard')
@UseGuards(AuthGuard('jwt'))
@ApiBearerAuth()
export class DashboardController {
constructor(private dashboardService: DashboardService) {}
@Get('stats')
async getStats(@Req() req: any) {
return this.dashboardService.getStats(req.user.userId);
}
@Get('progress')
async getProgress(@Req() req: any) {
return this.dashboardService.getProgress(req.user.userId);
}
@Get('favorites')
async getFavorites(@Req() req: any) {
return this.dashboardService.getFavorites(req.user.userId);
}
@Get('profile')
async getProfile(@Req() req: any) {
return this.dashboardService.getProfile(req.user.userId);
}
@Put('profile')
async updateProfile(@Req() req: any, @Body() body: { nickname?: string; avatar?: string }) {
return this.dashboardService.updateProfile(req.user.userId, body);
}
}
@@ -0,0 +1,9 @@
import { Module } from '@nestjs/common';
import { DashboardController } from './dashboard.controller';
import { DashboardService } from './dashboard.service';
@Module({
controllers: [DashboardController],
providers: [DashboardService],
})
export class DashboardModule {}
@@ -0,0 +1,201 @@
import { Injectable, NotFoundException } from '@nestjs/common';
import { PrismaService } from '../../prisma/prisma.service';
@Injectable()
export class DashboardService {
constructor(private prisma: PrismaService) {}
async getStats(userId: number) {
const user = await this.prisma.user.findUnique({
where: { id: userId, deletedAt: null },
select: {
nickname: true,
avatar: true,
memberPlan: true,
memberExpire: true,
sandboxDaily: true,
createdAt: true,
},
});
if (!user) throw new NotFoundException('用户不存在');
const learnRecords = await this.prisma.learnRecord.findMany({
where: { userId },
select: { courseId: true, completed: true },
});
const courseIds = [...new Set(learnRecords.map(r => r.courseId))];
const completedCount = learnRecords.filter(r => r.completed).length;
const inProgressCourses = courseIds.length;
const favoriteCount = await this.prisma.promptFavorite.count({ where: { userId } });
const today = new Date();
today.setHours(0, 0, 0, 0);
const todayCount = await this.prisma.learnRecord.count({
where: { userId, updatedAt: { gte: today } },
});
const firstRecord = await this.prisma.learnRecord.findFirst({
where: { userId },
orderBy: { createdAt: 'asc' },
select: { createdAt: true },
});
const studyDays = firstRecord
? Math.max(1, Math.ceil((Date.now() - firstRecord.createdAt.getTime()) / 86400000))
: 0;
return {
user: {
nickname: user.nickname,
avatar: user.avatar,
memberPlan: user.memberPlan,
memberExpire: user.memberExpire,
sandboxDaily: user.sandboxDaily,
joinedAt: user.createdAt,
},
stats: {
inProgressCourses,
completedLessons: completedCount,
favoritePrompts: favoriteCount,
studyDays,
todayLearned: todayCount,
},
};
}
async getProgress(userId: number) {
const learnRecords = await this.prisma.learnRecord.findMany({
where: { userId },
include: {
course: { select: { id: true, title: true, cover: true } },
lesson: { select: { id: true, title: true } },
},
orderBy: { updatedAt: 'desc' },
});
const courseMap = new Map<number, { course: any; lessons: any[]; completedCount: number; totalCount: number }>();
for (const record of learnRecords) {
if (!courseMap.has(record.courseId)) {
const totalLessons = await this.prisma.lesson.count({
where: { chapter: { courseId: record.courseId } },
});
courseMap.set(record.courseId, {
course: record.course,
lessons: [],
completedCount: 0,
totalCount: totalLessons,
});
}
const entry = courseMap.get(record.courseId)!;
entry.lessons.push({
id: record.lesson.id,
title: record.lesson.title,
completed: record.completed,
progress: record.progress,
updatedAt: record.updatedAt,
});
if (record.completed) entry.completedCount++;
}
const courses = Array.from(courseMap.values()).map(entry => ({
course: entry.course,
progress: entry.totalCount > 0 ? Math.round((entry.completedCount / entry.totalCount) * 100) : 0,
completedCount: entry.completedCount,
totalCount: entry.totalCount,
recentLessons: entry.lessons.slice(0, 5),
}));
const recentRecords = learnRecords.slice(0, 10).map(r => ({
lessonId: r.lesson.id,
lessonTitle: r.lesson.title,
courseId: r.course.id,
courseTitle: r.course.title,
completed: r.completed,
progress: r.progress,
updatedAt: r.updatedAt,
}));
return { courses, recentRecords };
}
async getFavorites(userId: number) {
const favorites = await this.prisma.promptFavorite.findMany({
where: { userId },
include: {
prompt: {
select: {
id: true,
title: true,
description: true,
model: true,
viewCount: true,
likeCount: true,
createdAt: true,
},
},
},
orderBy: { createdAt: 'desc' },
});
return favorites.map(f => ({
id: f.id,
promptId: f.prompt.id,
title: f.prompt.title,
description: f.prompt.description,
model: f.prompt.model,
viewCount: f.prompt.viewCount,
likeCount: f.prompt.likeCount,
favoritedAt: f.createdAt,
}));
}
async getProfile(userId: number) {
const user = await this.prisma.user.findUnique({
where: { id: userId, deletedAt: null },
select: {
id: true,
phone: true,
email: true,
nickname: true,
avatar: true,
status: true,
memberPlan: true,
memberExpire: true,
sandboxDaily: true,
createdAt: true,
},
});
if (!user) throw new NotFoundException('用户不存在');
return user;
}
async updateProfile(userId: number, data: { nickname?: string; avatar?: string }) {
const user = await this.prisma.user.findUnique({
where: { id: userId, deletedAt: null },
});
if (!user) throw new NotFoundException('用户不存在');
return this.prisma.user.update({
where: { id: userId },
data: {
...(data.nickname !== undefined && { nickname: data.nickname }),
...(data.avatar !== undefined && { avatar: data.avatar }),
},
select: {
id: true,
phone: true,
email: true,
nickname: true,
avatar: true,
status: true,
memberPlan: true,
memberExpire: true,
sandboxDaily: true,
createdAt: true,
},
});
}
}
@@ -0,0 +1,90 @@
import { Controller, Get, Post, Put, Delete, Body, Param, Query, UseGuards, Req } from '@nestjs/common';
import { ApiTags, ApiBearerAuth } from '@nestjs/swagger';
import { AuthGuard } from '@nestjs/passport';
import { EnterpriseService } from './enterprise.service';
@ApiTags('企业版')
@Controller('enterprise')
export class EnterpriseController {
constructor(private enterpriseService: EnterpriseService) {}
@Post('organizations')
@UseGuards(AuthGuard('jwt'))
@ApiBearerAuth()
async create(@Req() req: any, @Body() body: { name: string; description?: string; contactName?: string; contactPhone?: string }) {
return this.enterpriseService.createOrganization(req.user.userId, body);
}
@Get('organizations')
@UseGuards(AuthGuard('jwt'))
@ApiBearerAuth()
async list(@Query('page') page?: string, @Query('pageSize') pageSize?: string) {
return this.enterpriseService.listOrganizations(
page ? parseInt(page) : 1,
pageSize ? parseInt(pageSize) : 20,
);
}
@Get('organizations/:id')
@UseGuards(AuthGuard('jwt'))
@ApiBearerAuth()
async get(@Param('id') id: string) {
return this.enterpriseService.getOrganization(parseInt(id));
}
@Put('organizations/:id')
@UseGuards(AuthGuard('jwt'))
@ApiBearerAuth()
async update(@Param('id') id: string, @Body() body: { name?: string; description?: string; contactName?: string; contactPhone?: string }) {
return this.enterpriseService.updateOrganization(parseInt(id), body);
}
@Post('organizations/:id/members')
@UseGuards(AuthGuard('jwt'))
@ApiBearerAuth()
async addMember(@Req() req: any, @Param('id') id: string, @Body() body: { userId: number; role?: string }) {
return this.enterpriseService.addMember(parseInt(id), req.user.userId, body.userId, body.role);
}
@Delete('organizations/:orgId/members/:userId')
@UseGuards(AuthGuard('jwt'))
@ApiBearerAuth()
async removeMember(@Req() req: any, @Param('orgId') orgId: string, @Param('userId') userId: string) {
return this.enterpriseService.removeMember(parseInt(orgId), parseInt(userId), req.user.userId);
}
@Post('organizations/:id/assignments')
@UseGuards(AuthGuard('jwt'))
@ApiBearerAuth()
async assignCourse(@Req() req: any, @Param('id') id: string, @Body() body: { courseId: number; deadline?: string }) {
return this.enterpriseService.assignCourse(parseInt(id), body.courseId, req.user.userId, body.deadline);
}
@Delete('organizations/:orgId/assignments/:courseId')
@UseGuards(AuthGuard('jwt'))
@ApiBearerAuth()
async removeAssignment(@Param('orgId') orgId: string, @Param('courseId') courseId: string) {
return this.enterpriseService.removeAssignment(parseInt(orgId), parseInt(courseId));
}
@Get('organizations/:id/progress')
@UseGuards(AuthGuard('jwt'))
@ApiBearerAuth()
async getProgress(@Param('id') id: string) {
return this.enterpriseService.getOrgProgress(parseInt(id));
}
@Get('organizations/:id/report')
@UseGuards(AuthGuard('jwt'))
@ApiBearerAuth()
async getReport(@Param('id') id: string) {
return this.enterpriseService.getOrganizationReport(parseInt(id));
}
@Get('my')
@UseGuards(AuthGuard('jwt'))
@ApiBearerAuth()
async myOrganizations(@Req() req: any) {
return this.enterpriseService.getMyOrganizations(req.user.userId);
}
}
@@ -0,0 +1,10 @@
import { Module } from '@nestjs/common';
import { EnterpriseController } from './enterprise.controller';
import { EnterpriseService } from './enterprise.service';
@Module({
controllers: [EnterpriseController],
providers: [EnterpriseService],
exports: [EnterpriseService],
})
export class EnterpriseModule {}
@@ -0,0 +1,221 @@
import { Injectable, NotFoundException, ConflictException, BadRequestException, ForbiddenException } from '@nestjs/common';
import { PrismaService } from '../../prisma/prisma.service';
@Injectable()
export class EnterpriseService {
constructor(private prisma: PrismaService) {}
async createOrganization(userId: number, data: { name: string; description?: string; contactName?: string; contactPhone?: string }) {
const existing = await this.prisma.organization.findFirst({ where: { name: data.name } });
if (existing) throw new ConflictException('组织名称已存在');
const org = await this.prisma.organization.create({
data: { name: data.name, description: data.description, contactName: data.contactName, contactPhone: data.contactPhone },
});
await this.prisma.organizationMember.create({
data: { organizationId: org.id, userId, role: 'ADMIN' },
});
await this.prisma.organization.update({
where: { id: org.id },
data: { memberCount: { increment: 1 } },
});
return org;
}
async listOrganizations(page = 1, pageSize = 20) {
page = Number(page);
pageSize = Number(pageSize);
const skip = (page - 1) * pageSize;
const [items, total] = await Promise.all([
this.prisma.organization.findMany({
skip, take: pageSize,
orderBy: { createdAt: 'desc' },
include: { _count: { select: { members: true, assignments: true } } },
}),
this.prisma.organization.count(),
]);
return { items, total, page, pageSize };
}
async getOrganization(id: number) {
const org = await this.prisma.organization.findUnique({
where: { id },
include: {
_count: { select: { members: true, assignments: true } },
members: {
include: { user: { select: { id: true, nickname: true, avatar: true, email: true, phone: true } } },
},
assignments: {
include: { course: { select: { id: true, title: true, cover: true } } },
},
},
});
if (!org) throw new NotFoundException('组织不存在');
return org;
}
async updateOrganization(id: number, data: { name?: string; description?: string; contactName?: string; contactPhone?: string }) {
const org = await this.prisma.organization.findUnique({ where: { id } });
if (!org) throw new NotFoundException('组织不存在');
return this.prisma.organization.update({ where: { id }, data });
}
async addMember(orgId: number, adminId: number, userId: number, role = 'MEMBER') {
const org = await this.prisma.organization.findUnique({ where: { id: orgId } });
if (!org) throw new NotFoundException('组织不存在');
const caller = await this.prisma.organizationMember.findUnique({
where: { organizationId_userId: { organizationId: orgId, userId: adminId } },
});
if (!caller || caller.role !== 'ADMIN') throw new ForbiddenException('只有管理员可管理成员');
const membership = await this.prisma.organizationMember.findUnique({
where: { organizationId_userId: { organizationId: orgId, userId } },
});
if (membership) throw new ConflictException('该用户已是组织成员');
await this.prisma.organizationMember.create({
data: { organizationId: orgId, userId, role },
});
await this.prisma.organization.update({
where: { id: orgId },
data: { memberCount: { increment: 1 } },
});
return { added: true };
}
async removeMember(orgId: number, userId: number, callerId: number) {
const membership = await this.prisma.organizationMember.findUnique({
where: { organizationId_userId: { organizationId: orgId, userId } },
});
if (!membership) throw new NotFoundException('该用户不是组织成员');
if (membership.role === 'ADMIN') throw new BadRequestException('不能移除管理员');
const caller = await this.prisma.organizationMember.findUnique({
where: { organizationId_userId: { organizationId: orgId, userId: callerId } },
});
if (!caller || caller.role !== 'ADMIN') throw new ForbiddenException('只有管理员可管理成员');
await this.prisma.organizationMember.delete({ where: { id: membership.id } });
await this.prisma.organization.update({
where: { id: orgId },
data: { memberCount: { decrement: 1 } },
});
return { removed: true };
}
async assignCourse(orgId: number, courseId: number, assignedBy: number, deadline?: string) {
const [org, course] = await Promise.all([
this.prisma.organization.findUnique({ where: { id: orgId } }),
this.prisma.course.findUnique({ where: { id: courseId } }),
]);
if (!org) throw new NotFoundException('组织不存在');
if (!course) throw new NotFoundException('课程不存在');
const existing = await this.prisma.courseAssignment.findFirst({
where: { organizationId: orgId, courseId },
});
if (existing) throw new ConflictException('该课程已分配给此组织');
return this.prisma.courseAssignment.create({
data: {
organizationId: orgId,
courseId,
assignedBy,
deadline: deadline ? new Date(deadline) : undefined,
},
include: { course: { select: { id: true, title: true, cover: true } } },
});
}
async removeAssignment(orgId: number, courseId: number) {
const assignment = await this.prisma.courseAssignment.findFirst({
where: { organizationId: orgId, courseId },
});
if (!assignment) throw new NotFoundException('未找到该课程分配');
await this.prisma.courseAssignment.delete({ where: { id: assignment.id } });
return { removed: true };
}
async getOrgProgress(orgId: number) {
const members = await this.prisma.organizationMember.findMany({
where: { organizationId: orgId, status: 'ACTIVE' },
select: { userId: true },
});
const userIds = members.map(m => m.userId);
const assignments = await this.prisma.courseAssignment.findMany({
where: { organizationId: orgId },
include: { course: { select: { id: true, title: true } } },
});
const courseIds = assignments.map(a => a.courseId);
const records = await this.prisma.learnRecord.findMany({
where: { userId: { in: userIds }, courseId: { in: courseIds } },
});
const totalLessons = await this.prisma.lesson.count({
where: { chapter: { courseId: { in: courseIds } } },
});
const completedCount = records.filter(r => r.completed).length;
const activeMembers = userIds.length;
const progressByCourse = courseIds.map(courseId => {
const courseLessons = records.filter(r => r.courseId === courseId);
const uniqueLessons = new Set(courseLessons.map(r => r.lessonId));
return {
courseId,
completedLessons: courseLessons.filter(r => r.completed).length,
totalUniqueLessons: uniqueLessons.size,
};
});
return {
totalMembers: activeMembers,
totalCourses: courseIds.length,
completedLessons: completedCount,
totalLessons,
completionRate: totalLessons > 0 ? Math.round((completedCount / (totalLessons * Math.max(activeMembers, 1))) * 100) : 0,
progressByCourse,
};
}
async getMyOrganizations(userId: number) {
const memberships = await this.prisma.organizationMember.findMany({
where: { userId },
include: {
organization: {
include: { _count: { select: { members: true, assignments: true } } },
},
},
});
return memberships.map(m => ({ ...m.organization, role: m.role }));
}
async getOrganizationReport(orgId: number) {
const org = await this.getOrganization(orgId);
const progress = await this.getOrgProgress(orgId);
const memberProgress = await Promise.all(
org.members.map(async (member) => {
const records = await this.prisma.learnRecord.count({
where: { userId: member.user.id, completed: true },
});
return { user: member.user, completedLessons: records };
}),
);
return {
organization: { id: org.id, name: org.name, memberCount: org._count.members },
summary: progress,
memberProgress,
assignments: org.assignments,
};
}
}
@@ -0,0 +1,158 @@
import { Test, TestingModule } from '@nestjs/testing';
import { NotFoundException, ConflictException, BadRequestException } from '@nestjs/common';
import { EnterpriseService } from '../enterprise.service';
import { PrismaService } from '../../../prisma/prisma.service';
describe('EnterpriseService', () => {
let service: EnterpriseService;
let prisma: PrismaService;
const mockPrisma = {
organization: {
findFirst: jest.fn(),
findUnique: jest.fn(),
findMany: jest.fn(),
create: jest.fn(),
update: jest.fn(),
count: jest.fn(),
},
organizationMember: {
findUnique: jest.fn(),
findMany: jest.fn(),
create: jest.fn(),
delete: jest.fn(),
},
courseAssignment: {
findFirst: jest.fn(),
findMany: jest.fn(),
create: jest.fn(),
delete: jest.fn(),
},
course: {
findUnique: jest.fn(),
},
learnRecord: {
findMany: jest.fn(),
count: jest.fn(),
},
lesson: {
count: jest.fn(),
},
user: {
findUnique: jest.fn(),
},
};
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [
EnterpriseService,
{ provide: PrismaService, useValue: mockPrisma },
],
}).compile();
service = module.get<EnterpriseService>(EnterpriseService);
prisma = module.get<PrismaService>(PrismaService);
jest.clearAllMocks();
});
describe('createOrganization', () => {
it('should create organization and add creator as admin', async () => {
mockPrisma.organization.findFirst.mockResolvedValue(null);
mockPrisma.organization.create.mockResolvedValue({ id: 1, name: '测试企业', memberCount: 0 });
mockPrisma.organizationMember.create.mockResolvedValue({});
mockPrisma.organization.update.mockResolvedValue({});
const result = await service.createOrganization(1, { name: '测试企业' });
expect(result).toEqual({ id: 1, name: '测试企业', memberCount: 0 });
expect(mockPrisma.organization.create).toHaveBeenCalledWith({
data: { name: '测试企业', description: undefined, contactName: undefined, contactPhone: undefined },
});
expect(mockPrisma.organizationMember.create).toHaveBeenCalledWith({
data: { organizationId: 1, userId: 1, role: 'ADMIN' },
});
});
it('should throw ConflictException if name exists', async () => {
mockPrisma.organization.findFirst.mockResolvedValue({ id: 1 });
await expect(service.createOrganization(1, { name: '测试企业' }))
.rejects.toThrow(ConflictException);
});
});
describe('getOrganization', () => {
it('should return organization with members and assignments', async () => {
const mockOrg = {
id: 1, name: '测试企业',
_count: { members: 2, assignments: 1 },
members: [{ id: 1, userId: 1, user: { id: 1, nickname: '用户1' } }],
assignments: [{ id: 1, courseId: 1, course: { id: 1, title: '课程1' } }],
};
mockPrisma.organization.findUnique.mockResolvedValue(mockOrg);
const result = await service.getOrganization(1);
expect(result).toEqual(mockOrg);
});
it('should throw NotFoundException if not found', async () => {
mockPrisma.organization.findUnique.mockResolvedValue(null);
await expect(service.getOrganization(999)).rejects.toThrow(NotFoundException);
});
});
describe('addMember', () => {
it('should add member to organization', async () => {
mockPrisma.organization.findUnique.mockResolvedValue({ id: 1 });
mockPrisma.organizationMember.findUnique
.mockResolvedValueOnce({ role: 'ADMIN' }) // caller is admin
.mockResolvedValueOnce(null); // user not yet member
mockPrisma.organizationMember.create.mockResolvedValue({});
mockPrisma.organization.update.mockResolvedValue({});
const result = await service.addMember(1, 1, 2);
expect(result).toEqual({ added: true });
});
it('should throw ConflictException if already member', async () => {
mockPrisma.organization.findUnique.mockResolvedValue({ id: 1 });
mockPrisma.organizationMember.findUnique
.mockResolvedValueOnce({ role: 'ADMIN' }) // caller is admin
.mockResolvedValueOnce({ id: 1 }); // user already member
await expect(service.addMember(1, 1, 2)).rejects.toThrow(ConflictException);
});
});
describe('assignCourse', () => {
it('should assign course to organization', async () => {
mockPrisma.organization.findUnique.mockResolvedValue({ id: 1 });
mockPrisma.course.findUnique.mockResolvedValue({ id: 1 });
mockPrisma.courseAssignment.findFirst.mockResolvedValue(null);
mockPrisma.courseAssignment.create.mockResolvedValue({
id: 1, courseId: 1, course: { id: 1, title: '课程1' },
});
const result = await service.assignCourse(1, 1, 1);
expect(result).toHaveProperty('id');
});
it('should throw NotFoundException if org not found', async () => {
mockPrisma.organization.findUnique.mockResolvedValue(null);
await expect(service.assignCourse(1, 1, 1)).rejects.toThrow(NotFoundException);
});
});
describe('listOrganizations', () => {
it('should return paginated organizations', async () => {
mockPrisma.organization.findMany.mockResolvedValue([{ id: 1, name: '测试企业' }]);
mockPrisma.organization.count.mockResolvedValue(1);
const result = await service.listOrganizations(1, 20);
expect(result.items).toHaveLength(1);
expect(result.total).toBe(1);
expect(result.page).toBe(1);
});
});
});
@@ -0,0 +1,22 @@
import { Controller, Get, UseGuards, Req } from '@nestjs/common';
import { ApiTags, ApiBearerAuth } from '@nestjs/swagger';
import { AuthGuard } from '@nestjs/passport';
import { LearningService } from './learning.service';
@ApiTags('学习分析')
@Controller('learning')
@UseGuards(AuthGuard('jwt'))
@ApiBearerAuth()
export class LearningController {
constructor(private learningService: LearningService) {}
@Get('analytics')
async getAnalytics(@Req() req: any) {
return this.learningService.getAnalytics(req.user.userId);
}
@Get('path')
async getLearningPath(@Req() req: any) {
return this.learningService.getLearningPath(req.user.userId);
}
}
@@ -0,0 +1,10 @@
import { Module } from '@nestjs/common';
import { LearningController } from './learning.controller';
import { LearningService } from './learning.service';
@Module({
controllers: [LearningController],
providers: [LearningService],
exports: [LearningService],
})
export class LearningModule {}
@@ -0,0 +1,192 @@
import { Injectable } from '@nestjs/common';
import { PrismaService } from '../../prisma/prisma.service';
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 COURSE_RECOMMENDATIONS: Record<string, { title: string; url: string }[]> = {
'ai-basics': [
{ title: 'AI 通识:零基础入门', url: '/courses' },
{ title: '大模型原理与应用', url: '/courses' },
],
'prompt-engineering': [
{ title: '提示词工程从入门到精通', url: '/courses' },
{ title: '高级 Prompt 技巧', url: '/prompts' },
],
'programming': [
{ title: '用 Python 入门 AI 编程', url: '/courses' },
{ title: 'AI 辅助编程实战', url: '/sandbox' },
],
'writing': [
{ title: 'AI 写作实战指南', url: '/prompts/workshop' },
{ title: '内容创作与润色技巧', url: '/courses' },
],
'english': [
{ title: 'AI 辅助英语学习', url: '/sandbox' },
{ title: '英语写作提升课程', url: '/courses' },
],
'data-science': [
{ title: '数据分析入门', url: '/courses' },
{ title: 'Python 数据分析', url: '/courses' },
],
'office': [
{ title: '用 AI 提升 10 倍办公效率', url: '/courses' },
{ title: 'AI 办公自动化', url: '/courses' },
],
'career': [
{ title: 'AI 时代职业规划', url: '/courses' },
{ title: '面试技巧与简历优化', url: '/sandbox' },
],
};
@Injectable()
export class LearningService {
constructor(private prisma: PrismaService) {}
async getAnalytics(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 domain of KNOWLEDGE_DOMAINS) {
domainCounts[domain.id] = 0;
}
for (const session of sessions) {
const searchText = `${session.title} ${session.messages || ''}`.toLowerCase();
for (const domain of KNOWLEDGE_DOMAINS) {
const matched = domain.keywords.some(kw => searchText.includes(kw));
if (matched) {
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 totalSessionsForUser = Math.max(totalSessions, 1);
const mastery = Math.min(Math.round((count / Math.max(totalSessionsForUser * 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 => (COURSE_RECOMMENDATIONS[d.id] || []).slice(0, 2))
: [{ title: '探索更多知识领域', url: '/sandbox' }];
return {
domains,
totalSessions,
weakDomains: weakDomains.map(d => d.name),
recommendations: [...new Map(recommendations.map(r => [r.title, r])).values()],
};
}
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(' ');
const stages = [
{
id: 'basics',
title: '认识大模型',
icon: '🤖',
description: '了解 AI 和大型语言模型的基本概念,学会如何使用 AI 工具。',
tasks: [
{ label: '了解 AI 基本概念', action: '向 AI 提问什么是人工智能', keyword: '人工智能' },
{ label: '认识大语言模型', action: '向 AI 提问什么是大模型', keyword: '大模型' },
{ label: '体验 AI 对话', action: '在沙盒中发起一次对话', keyword: '' },
],
links: [
{ title: 'AI 通识:零基础入门', url: '/courses' },
{ title: '打开 AI 沙盒', url: '/sandbox' },
],
},
{
id: 'prompt',
title: '提示词工程',
icon: '✍️',
description: '学习如何编写高质量的提示词,掌握与 AI 高效沟通的技巧。',
tasks: [
{ label: '了解提示词基础', action: '询问提示词编写技巧', keyword: '提示词' },
{ label: '练习提示词编写', action: '在提示词工坊中测试', keyword: '' },
{ label: '保存优质提示词', action: '将好的提示词保存到库中', keyword: '' },
],
links: [
{ title: '提示词工坊', url: '/prompts/workshop' },
{ title: '提示词库', url: '/prompts' },
],
},
{
id: 'advanced',
title: '模型微调与高级应用',
icon: '⚙️',
description: '了解模型微调、RAG、Function Calling 等高级技术。',
tasks: [
{ label: '了解模型微调', action: '询问什么是模型微调', keyword: '微调' },
{ label: '了解 RAG', action: '询问什么是 RAG 检索增强', keyword: 'rag' },
{ label: '了解 Function Calling', action: '询问 function calling 是什么', keyword: 'function calling' },
],
links: [
{ title: 'AI 沙盒 - 对比模式', url: '/sandbox/compare' },
{ title: '代码沙盒', url: '/sandbox/code' },
],
},
{
id: 'agent',
title: 'Agent 开发',
icon: '🚀',
description: '学习构建 AI Agent,实现自动化任务和复杂工作流。',
tasks: [
{ label: '了解 AI Agent', action: '询问什么是 AI Agent', keyword: 'agent' },
{ label: '学习工具调用', action: '询问 AI 工具调用机制', keyword: 'tool' },
{ label: '实践项目', action: '尝试用 AI 构建一个小项目', keyword: '' },
],
links: [
{ title: '代码沙盒 - 运行项目', url: '/sandbox/code' },
{ title: '对比实验室', url: '/sandbox/compare' },
],
},
];
return 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,
};
});
}
}
@@ -0,0 +1,19 @@
import { Controller, Get, Param, Query } from '@nestjs/common';
import { ApiTags } from '@nestjs/swagger';
import { ModelsService } from './models.service';
@ApiTags('AI 模型')
@Controller('models')
export class ModelsController {
constructor(private modelsService: ModelsService) {}
@Get()
async findAll(@Query('featured') featured?: string) {
return this.modelsService.findAll({ featured: featured === 'true' });
}
@Get(':id')
async findById(@Param('id') id: string) {
return this.modelsService.findById(parseInt(id, 10));
}
}
@@ -0,0 +1,9 @@
import { Module } from '@nestjs/common';
import { ModelsController } from './models.controller';
import { ModelsService } from './models.service';
@Module({
controllers: [ModelsController],
providers: [ModelsService],
})
export class ModelsModule {}
@@ -0,0 +1,21 @@
import { Injectable } from '@nestjs/common';
import { PrismaService } from '../../prisma/prisma.service';
@Injectable()
export class ModelsService {
constructor(private prisma: PrismaService) {}
async findAll(params: { featured?: boolean }) {
const where: any = { status: 'ACTIVE' };
if (params.featured) where.isFeatured = true;
return this.prisma.aiModel.findMany({
where,
orderBy: { sortOrder: 'asc' },
});
}
async findById(id: number) {
return this.prisma.aiModel.findUnique({ where: { id } });
}
}
@@ -0,0 +1,35 @@
import { Controller, Get, Patch, Param, Query, Req, UseGuards } from '@nestjs/common';
import { ApiTags, ApiBearerAuth } from '@nestjs/swagger';
import { AuthGuard } from '@nestjs/passport';
import { NotificationService } from './notification.service';
@ApiTags('通知')
@Controller('notifications')
@UseGuards(AuthGuard('jwt'))
@ApiBearerAuth()
export class NotificationController {
constructor(private notificationService: NotificationService) {}
@Get()
async findAll(@Req() req: any, @Query('page') page?: string, @Query('pageSize') pageSize?: string) {
return this.notificationService.findAll(req.user.userId, page ? parseInt(page) : 1, pageSize ? parseInt(pageSize) : 20);
}
@Get('unread')
async countUnread(@Req() req: any) {
const count = await this.notificationService.countUnread(req.user.userId);
return { count };
}
@Patch(':id/read')
async markAsRead(@Req() req: any, @Param('id') id: string) {
await this.notificationService.markAsRead(parseInt(id), req.user.userId);
return { ok: true };
}
@Patch('read-all')
async markAllAsRead(@Req() req: any) {
await this.notificationService.markAllAsRead(req.user.userId);
return { ok: true };
}
}
@@ -0,0 +1,10 @@
import { Module } from '@nestjs/common';
import { NotificationController } from './notification.controller';
import { NotificationService } from './notification.service';
@Module({
controllers: [NotificationController],
providers: [NotificationService],
exports: [NotificationService],
})
export class NotificationModule {}
@@ -0,0 +1,42 @@
import { Injectable } from '@nestjs/common';
import { PrismaService } from '../../prisma/prisma.service';
@Injectable()
export class NotificationService {
constructor(private prisma: PrismaService) {}
async create(data: { userId: number; type: string; title: string; content?: string; link?: string; relatedId?: number }) {
return this.prisma.notification.create({ data });
}
async findAll(userId: number, page = 1, 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 { items, total, page, pageSize, unread: items.filter(n => !n.isRead).length };
}
async countUnread(userId: number) {
return this.prisma.notification.count({ where: { userId, isRead: false } });
}
async markAsRead(id: number, userId: number) {
return this.prisma.notification.updateMany({
where: { id, userId },
data: { isRead: true },
});
}
async markAllAsRead(userId: number) {
return this.prisma.notification.updateMany({
where: { userId, isRead: false },
data: { isRead: true },
});
}
}
@@ -0,0 +1,42 @@
import { Controller, Post, Get, Body, Param, Query, UseGuards, Req } from '@nestjs/common';
import { ApiTags, ApiBearerAuth, ApiBody } from '@nestjs/swagger';
import { IsNumber, IsString, IsOptional, IsIn } from 'class-validator';
import { AuthGuard } from '@nestjs/passport';
import { OrdersService } from './orders.service';
class CreateOrderDto {
@IsNumber()
amount: number;
@IsString()
@IsIn(['MONTHLY', 'YEARLY', 'COURSE'])
planType: string;
@IsOptional()
@IsString()
payChannel?: string;
}
@ApiTags('订单')
@Controller('orders')
@UseGuards(AuthGuard('jwt'))
@ApiBearerAuth()
export class OrdersController {
constructor(private ordersService: OrdersService) {}
@Post('create')
@ApiBody({ type: CreateOrderDto })
async create(@Req() req: any, @Body() body: CreateOrderDto) {
return this.ordersService.create(req.user.userId, body);
}
@Get()
async findByUser(@Req() req: any, @Query() query: { page?: number; pageSize?: number }) {
return this.ordersService.findByUser(req.user.userId, query);
}
@Get(':orderNo')
async findByOrderNo(@Param('orderNo') orderNo: string) {
return this.ordersService.findByOrderNo(orderNo);
}
}
@@ -0,0 +1,12 @@
import { Module } from '@nestjs/common';
import { OrdersController } from './orders.controller';
import { OrdersService } from './orders.service';
import { PaymentModule } from '../payment/payment.module';
@Module({
imports: [PaymentModule],
controllers: [OrdersController],
providers: [OrdersService],
exports: [OrdersService],
})
export class OrdersModule {}
@@ -0,0 +1,83 @@
import { Injectable } from '@nestjs/common';
import { PrismaService } from '../../prisma/prisma.service';
import { PaymentService } from '../payment/payment.service';
@Injectable()
export class OrdersService {
constructor(
private prisma: PrismaService,
private paymentService: PaymentService,
) {}
async create(userId: number, data: { amount: number; planType: string; payChannel?: string }) {
const orderNo = `YZR${Date.now()}${Math.random().toString(36).slice(2, 8).toUpperCase()}`;
const order = await this.prisma.order.create({
data: {
orderNo,
userId,
amount: data.amount,
planType: data.planType,
payChannel: data.payChannel || 'wxpay',
},
});
// If paying via WeChat, create unified order
if (data.payChannel === 'wxpay' || !data.payChannel) {
try {
const planLabels: Record<string, string> = {
MONTHLY: '宇之然AI月卡会员',
YEARLY: '宇之然AI年卡会员',
};
const payResult = await this.paymentService.createUnifiedOrder({
description: planLabels[data.planType] || '宇之然AI会员充值',
outTradeNo: orderNo,
amount: data.amount,
});
return { order, payResult };
} catch {
return { order, payResult: null };
}
}
return { order };
}
async findByUser(userId: number, params: { page?: number; pageSize?: number }) {
const page = Number(params.page ?? 1);
const pageSize = Number(params.pageSize ?? 20);
const [items, total] = await Promise.all([
this.prisma.order.findMany({
where: { userId },
skip: (page - 1) * pageSize,
take: pageSize,
orderBy: { createdAt: 'desc' },
}),
this.prisma.order.count({ where: { userId } }),
]);
return { items, total, page, pageSize };
}
async findByOrderNo(orderNo: string) {
return this.prisma.order.findUnique({ where: { orderNo } });
}
async getCurrentSubscription(userId: number) {
const now = new Date();
return this.prisma.subscription.findFirst({
where: {
userId,
status: 'ACTIVE',
endDate: { gt: now },
},
orderBy: { endDate: 'desc' },
});
}
async getSubscriptions(userId: number) {
return this.prisma.subscription.findMany({
where: { userId },
orderBy: { createdAt: 'desc' },
});
}
}
@@ -0,0 +1,22 @@
import { Controller, Get, Post, Body, UseGuards, Req } from '@nestjs/common';
import { ApiTags, ApiBearerAuth } from '@nestjs/swagger';
import { AuthGuard } from '@nestjs/passport';
import { OrdersService } from './orders.service';
@ApiTags('订阅')
@Controller('subscriptions')
@UseGuards(AuthGuard('jwt'))
@ApiBearerAuth()
export class SubscriptionsController {
constructor(private ordersService: OrdersService) {}
@Get('current')
async getCurrentSubscription(@Req() req: any) {
return this.ordersService.getCurrentSubscription(req.user.userId);
}
@Get()
async getSubscriptions(@Req() req: any) {
return this.ordersService.getSubscriptions(req.user.userId);
}
}
@@ -0,0 +1,169 @@
import { Test, TestingModule } from '@nestjs/testing';
import { OrdersService } from '../orders.service';
import { PrismaService } from '../../../prisma/prisma.service';
import { PaymentService } from '../../payment/payment.service';
describe('OrdersService', () => {
let service: OrdersService;
let prisma: PrismaService;
let paymentService: PaymentService;
const mockPrisma = {
order: {
create: jest.fn(),
findMany: jest.fn(),
findUnique: jest.fn(),
update: jest.fn(),
count: jest.fn(),
},
subscription: {
findFirst: jest.fn(),
findMany: jest.fn(),
},
};
const mockPaymentService = {
createUnifiedOrder: jest.fn(),
};
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [
OrdersService,
{ provide: PrismaService, useValue: mockPrisma },
{ provide: PaymentService, useValue: mockPaymentService },
],
}).compile();
service = module.get<OrdersService>(OrdersService);
prisma = module.get<PrismaService>(PrismaService);
paymentService = module.get<PaymentService>(PaymentService);
jest.clearAllMocks();
});
describe('create', () => {
it('should create order and call payment service for wxpay', async () => {
const mockOrder = {
id: 1,
orderNo: 'YZR123',
amount: 29.9,
planType: 'MONTHLY',
payChannel: 'wxpay',
};
mockPrisma.order.create.mockResolvedValue(mockOrder);
mockPaymentService.createUnifiedOrder.mockResolvedValue({
prepay_id: 'wx123',
nonceStr: 'abc',
});
const result = await service.create(1, {
amount: 29.9,
planType: 'MONTHLY',
payChannel: 'wxpay',
});
expect(result).toHaveProperty('order');
expect(result).toHaveProperty('payResult');
expect(mockPrisma.order.create).toHaveBeenCalledWith({
data: expect.objectContaining({
userId: 1,
amount: 29.9,
planType: 'MONTHLY',
payChannel: 'wxpay',
}),
});
});
it('should create order without payment for non-wxpay channels', async () => {
const mockOrder = {
id: 1,
orderNo: 'YZR123',
amount: 199,
planType: 'YEARLY',
payChannel: 'alipay',
};
mockPrisma.order.create.mockResolvedValue(mockOrder);
const result = await service.create(1, {
amount: 199,
planType: 'YEARLY',
payChannel: 'alipay',
});
expect(result).toEqual({ order: mockOrder });
expect(mockPaymentService.createUnifiedOrder).not.toHaveBeenCalled();
});
});
describe('findByUser', () => {
it('should return paginated orders for user', async () => {
const mockOrders = [
{ id: 1, orderNo: 'YZR123', amount: 29.9 }
];
mockPrisma.order.findMany.mockResolvedValue(mockOrders);
mockPrisma.order.count.mockResolvedValue(1);
const result = await service.findByUser(1, { page: 1, pageSize: 20 });
expect(result).toEqual({
items: mockOrders,
total: 1,
page: 1,
pageSize: 20,
});
});
});
describe('findByOrderNo', () => {
it('should return order by orderNo', async () => {
const mockOrder = { id: 1, orderNo: 'YZR123' };
mockPrisma.order.findUnique.mockResolvedValue(mockOrder);
const result = await service.findByOrderNo('YZR123');
expect(result).toEqual(mockOrder);
expect(mockPrisma.order.findUnique).toHaveBeenCalledWith({
where: { orderNo: 'YZR123' },
});
});
});
describe('getCurrentSubscription', () => {
it('should return active subscription', async () => {
const mockSub = {
id: 1,
userId: 1,
plan: 'YEARLY',
status: 'ACTIVE',
endDate: new Date(Date.now() + 86400000), // 明天到期
};
mockPrisma.subscription.findFirst.mockResolvedValue(mockSub);
const result = await service.getCurrentSubscription(1);
expect(result).toEqual(mockSub);
});
it('should return null if no active subscription', async () => {
mockPrisma.subscription.findFirst.mockResolvedValue(null);
const result = await service.getCurrentSubscription(1);
expect(result).toBeNull();
});
});
describe('getSubscriptions', () => {
it('should return all subscriptions for user', async () => {
const mockSubs = [
{ id: 1, plan: 'MONTHLY' },
{ id: 2, plan: 'YEARLY' },
];
mockPrisma.subscription.findMany.mockResolvedValue(mockSubs);
const result = await service.getSubscriptions(1);
expect(result).toEqual(mockSubs);
});
});
});
@@ -0,0 +1,69 @@
import { Controller, Post, Get, Body, Req, Headers, HttpCode, Query } from '@nestjs/common';
import { ApiTags, ApiOperation, ApiBody, ApiQuery } from '@nestjs/swagger';
import { IsString, IsNumber, IsOptional, IsIn } from 'class-validator';
import { PaymentService } from './payment.service';
class UnifiedOrderDto {
@IsString()
description: string;
@IsString()
outTradeNo: string;
@IsNumber()
amount: number;
@IsOptional()
@IsString()
openid?: string;
@IsOptional()
@IsIn(['JSAPI', 'NATIVE', 'MWEB'])
tradeType?: 'JSAPI' | 'NATIVE' | 'MWEB';
}
class RefundDto {
@IsString()
outTradeNo: string;
@IsNumber()
amount: number;
@IsOptional()
@IsString()
reason?: string;
}
@ApiTags('支付')
@Controller('payment')
export class PaymentController {
constructor(private paymentService: PaymentService) {}
@Post('wxpay/unified-order')
@ApiOperation({ summary: '微信支付统一下单' })
@ApiBody({ type: UnifiedOrderDto })
async unifiedOrder(@Body() body: UnifiedOrderDto) {
return this.paymentService.createUnifiedOrder(body);
}
@Post('wxpay/notify')
@HttpCode(200)
@ApiOperation({ summary: '微信支付回调通知' })
async notify(@Req() req: any, @Headers('wechatpay-signature') signature: string) {
return this.paymentService.handleNotify(req.body, signature);
}
@Post('wxpay/refund')
@ApiOperation({ summary: '微信支付退款' })
@ApiBody({ type: RefundDto })
async refund(@Body() body: RefundDto) {
return this.paymentService.refund(body.outTradeNo, body.amount, body.reason);
}
@Get('wxpay/query')
@ApiOperation({ summary: '查询微信支付订单' })
@ApiQuery({ name: 'outTradeNo', required: true })
async query(@Query('outTradeNo') outTradeNo: string) {
return this.paymentService.queryOrder(outTradeNo);
}
}
@@ -0,0 +1,12 @@
import { Module } from '@nestjs/common';
import { PaymentController } from './payment.controller';
import { PaymentService } from './payment.service';
import { PrismaModule } from '../../prisma/prisma.module';
@Module({
imports: [PrismaModule],
controllers: [PaymentController],
providers: [PaymentService],
exports: [PaymentService],
})
export class PaymentModule {}
@@ -0,0 +1,326 @@
import { Injectable, Logger } from '@nestjs/common';
import { PrismaService } from '../../prisma/prisma.service';
import * as path from 'path';
// WeChat Pay V3 SDK types
interface WxPayConfig {
appId: string;
mchId: string;
apiKey: string;
certPath: string;
keyPath: string;
notifyUrl: string;
}
export interface UnifiedOrderResult {
prepay_id: string;
nonceStr: string;
timeStamp: string;
package: string;
paySign: string;
signType: string;
}
@Injectable()
export class PaymentService {
private readonly logger = new Logger(PaymentService.name);
private config: WxPayConfig;
private wxPay: any;
constructor(private prisma: PrismaService) {
this.config = {
appId: process.env.WX_APPID || '',
mchId: process.env.WX_MCHID || process.env.WX_PAY_MCH_ID || '1108945993',
apiKey: process.env.WX_API_KEY || process.env.WX_PAY_API_KEY || '8Kj9mP2nQ5rT7vW1xY3zA4bC6dE8fG0h',
certPath: process.env.WX_CERT_PATH || path.resolve(__dirname, '../../../cert/key/apiclient_cert.pem'),
keyPath: process.env.WX_KEY_PATH || path.resolve(__dirname, '../../../cert/key/apiclient_key.pem'),
notifyUrl: process.env.WX_NOTIFY_URL || 'https://yuzhiran.com/api/v1/payment/wxpay/notify',
};
try {
const fs = require('fs');
const { WechatPay } = require('wechat-pay-nodejs');
this.wxPay = new WechatPay({
appid: this.config.appId,
mchid: this.config.mchId,
key: this.config.apiKey,
cert_private_content: fs.readFileSync(this.config.keyPath),
cert_public_content: fs.readFileSync(this.config.certPath),
});
this.logger.log(`微信支付初始化成功 (商户号: ${this.config.mchId})`);
} catch (err: any) {
this.logger.warn(`微信支付 SDK 初始化失败: ${err.message},将使用模拟模式`);
this.wxPay = null;
}
}
async createUnifiedOrder(params: {
description: string;
outTradeNo: string;
amount: number;
openid?: string;
tradeType?: 'JSAPI' | 'NATIVE' | 'MWEB';
}): Promise<UnifiedOrderResult & { codeUrl?: string }> {
const { description, outTradeNo, amount, openid, tradeType = 'JSAPI' } = params;
if (!this.wxPay) {
return { ...this.mockPayResult(outTradeNo, amount), codeUrl: 'mock://pay' };
}
try {
const baseParams = {
description,
out_trade_no: outTradeNo,
amount: { total: Math.round(amount * 100) },
notify_url: this.config.notifyUrl,
};
let result: any;
if (tradeType === 'NATIVE') {
result = await this.wxPay.prepayNative({
...baseParams,
product_id: outTradeNo,
});
} else if (tradeType === 'MWEB') {
result = await this.wxPay.prepayMweb({
...baseParams,
scene_info: { payer_client_ip: '127.0.0.1' },
});
} else {
result = await this.wxPay.prepayJsapi({
...baseParams,
payer: { openid: openid || 'oVtFy6Wv8L0lKJ9xG2rH3nM5pQ7sT1uZ' },
});
}
if (result.success && result.data) {
const response: any = {
prepay_id: result.data.package?.replace('prepay_id=', '') || result.data.prepay_id || '',
nonceStr: result.data.nonceStr || result.data.nonce_str,
timeStamp: result.data.timeStamp || String(Math.floor(Date.now() / 1000)),
paySign: result.data.paySign || result.data.sign,
signType: 'RSA',
};
if (result.data.code_url) response.codeUrl = result.data.code_url;
if (result.data.mweb_url) response.mwebUrl = result.data.mweb_url;
return response;
}
throw new Error(result.errMsg || '下单失败');
} catch (err: any) {
this.logger.error(`微信支付统一下单失败: ${err.message}`);
return { ...this.mockPayResult(outTradeNo, amount), codeUrl: 'mock://pay' };
}
}
async handleNotify(body: any, signature: string): Promise<{ code: string; message: string }> {
if (!this.wxPay) {
// 模拟模式:尝试更新订单状态
try {
const data = typeof body === 'string' ? JSON.parse(body) : body;
const outTradeNo = data.out_trade_no || (data.resource && data.resource.out_trade_no);
if (outTradeNo) {
await this.updateOrderAndMembership(outTradeNo, data.amount?.total || 0);
}
} catch (err) {
this.logger.warn(`模拟模式处理通知失败: ${err.message}`);
}
return { code: 'SUCCESS', message: '模拟模式-通知处理成功' };
}
try {
const verified = this.wxPay.verifySignature(body, signature);
if (!verified) {
return { code: 'FAIL', message: '签名验证失败' };
}
const data = typeof body === 'string' ? JSON.parse(body) : body;
const { event_type, resource } = data;
if (event_type === 'TRANSACTION.SUCCESS') {
const ciphertext = resource.ciphertext;
const associatedData = resource.associated_data;
const nonce = resource.nonce;
const decrypted = this.wxPay.decryptGCM(ciphertext, associatedData, nonce);
const payResult = typeof decrypted === 'string' ? JSON.parse(decrypted) : decrypted;
this.logger.log(`支付成功: ${payResult.out_trade_no}, 金额: ${payResult.amount.total}`);
// 更新订单状态和会员订阅
await this.updateOrderAndMembership(payResult.out_trade_no, payResult.amount.total);
return { code: 'SUCCESS', message: '支付成功' };
}
return { code: 'SUCCESS', message: '已接收' };
} catch (err: any) {
this.logger.error(`支付通知处理失败: ${err.message}`);
return { code: 'FAIL', message: err.message };
}
}
private async updateOrderAndMembership(outTradeNo: string, paidAmountTotal: number) {
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;
}
// 验证金额(微信支付单位:分,订单单位:元)
const paidAmount = paidAmountTotal / 100;
if (Math.abs(paidAmount - order.amount) > 0.01) {
this.logger.warn(`支付金额不一致: 订单${order.amount}元,支付${paidAmount}`);
}
// 更新订单状态为已支付
await this.prisma.order.update({
where: { id: order.id },
data: { status: 'PAID', paidAt: new Date() },
});
// 处理会员订阅(仅限MONTHLY/YEARLY计划)
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}`);
}
}
async refund(outTradeNo: string, amount: number, reason?: string) {
if (!this.wxPay) {
this.logger.log(`模拟退款: ${outTradeNo}`);
// 模拟退款成功后更新订单状态
try {
await this.prisma.order.update({
where: { orderNo: outTradeNo },
data: { status: 'REFUNDED' },
});
} catch(err) {
this.logger.warn(`模拟退款更新订单失败: ${err.message}`);
}
return { code: 'SUCCESS', message: '模拟退款成功' };
}
try {
const result = await this.wxPay.refunds({
out_trade_no: outTradeNo,
out_refund_no: `REFUND_${outTradeNo}_${Date.now()}`,
amount: {
refund: Math.round(amount * 100),
total: Math.round(amount * 100),
currency: 'CNY',
},
reason: reason || '用户申请退款',
});
// 退款成功后更新订单状态
if (result.success) {
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 } } },
});
if (!this.wxPay) {
return {
trade_state: localOrder?.status === 'PAID' ? 'SUCCESS' : 'NOTPAY',
out_trade_no: outTradeNo,
localStatus: localOrder?.status,
amount: localOrder?.amount,
planType: localOrder?.planType,
};
}
const wxResult = await this.wxPay.queryByOutTradeNo(outTradeNo);
return {
...wxResult,
localStatus: localOrder?.status,
localAmount: localOrder?.amount,
user: localOrder?.user,
};
}
private mockPayResult(outTradeNo: string, amount: number): UnifiedOrderResult {
const nonceStr = this.generateNonceStr();
const timeStamp = String(Math.floor(Date.now() / 1000));
const prepayId = `wx${Date.now()}${Math.random().toString(36).slice(2, 10)}`;
return {
prepay_id: prepayId,
nonceStr,
timeStamp,
package: `prepay_id=${prepayId}`,
paySign: 'MOCK_SIGN_FOR_DEVELOPMENT',
signType: 'RSA',
};
}
private generateNonceStr(): string {
return Math.random().toString(36).substring(2, 18) + Math.random().toString(36).substring(2, 18);
}
}
@@ -0,0 +1,19 @@
// @ts-nocheck
import { Test, TestingModule } from '@nestjs/testing';
import { PaymentService } from '../payment.service';
import { PrismaService } from '../../../prisma/prisma.service';
describe('PaymentService', () => {
let service: PaymentService;
beforeEach(async () => {
const module = await Test.createTestingModule({
providers: [PaymentService, { provide: PrismaService, useValue: {} }],
}).compile();
service = module.get<PaymentService>(PaymentService);
});
it('should be defined', () => {
expect(service).toBeDefined();
});
});
@@ -0,0 +1,44 @@
import { Controller, Get, Post, Body, Param, Query, UseGuards, Req } from '@nestjs/common';
import { ApiTags, ApiBearerAuth } from '@nestjs/swagger';
import { AuthGuard } from '@nestjs/passport';
import { PromptsService } from './prompts.service';
@ApiTags('提示词')
@Controller('prompts')
export class PromptsController {
constructor(private promptsService: PromptsService) {}
@Get()
async findAll(@Query() query: { page?: number; pageSize?: number; categoryId?: number; search?: string }) {
return this.promptsService.findAll(query);
}
@Get(':id')
async findById(@Param('id') id: string) {
return this.promptsService.findById(+id);
}
@Post()
@UseGuards(AuthGuard('jwt'))
@ApiBearerAuth()
async create(@Req() req: any, @Body() body: { title: string; content: string; description?: string; categoryId?: number; tags?: string; model?: string }) {
return this.promptsService.create({ ...body, authorId: req.user.userId });
}
@Post(':id/favorite')
@UseGuards(AuthGuard('jwt'))
@ApiBearerAuth()
async toggleFavorite(@Req() req: any, @Param('id') id: string) {
return this.promptsService.toggleFavorite(req.user.userId, +id);
}
@Get('favorites')
@UseGuards(AuthGuard('jwt'))
@ApiBearerAuth()
async findFavorites(@Req() req: any, @Query('page') page?: string, @Query('pageSize') pageSize?: string) {
return this.promptsService.findFavorites(req.user.userId, {
page: page ? parseInt(page) : undefined,
pageSize: pageSize ? parseInt(pageSize) : undefined,
});
}
}
@@ -0,0 +1,10 @@
import { Module } from '@nestjs/common';
import { PromptsController } from './prompts.controller';
import { PromptsService } from './prompts.service';
@Module({
controllers: [PromptsController],
providers: [PromptsService],
exports: [PromptsService],
})
export class PromptsModule {}
@@ -0,0 +1,82 @@
import { Injectable } from '@nestjs/common';
import { PrismaService } from '../../prisma/prisma.service';
@Injectable()
export class PromptsService {
constructor(private prisma: PrismaService) {}
async findAll(params: { page?: number; pageSize?: number; categoryId?: number; search?: string }) {
const page = Number(params.page ?? 1);
const pageSize = Number(params.pageSize ?? 20);
const { categoryId, search } = params;
const where: any = { status: 'PUBLISHED', deletedAt: null, isPublic: true };
if (categoryId) where.categoryId = categoryId;
if (search) {
where.OR = [
{ title: { contains: search } },
{ content: { contains: search } },
];
}
const [items, total] = await Promise.all([
this.prisma.prompt.findMany({
where,
skip: (page - 1) * pageSize,
take: pageSize,
orderBy: { likeCount: 'desc' },
select: {
id: true, title: true, description: true, content: true,
tags: true, model: true, viewCount: true, likeCount: true,
createdAt: true, category: true, author: { select: { nickname: true } },
},
}),
this.prisma.prompt.count({ where }),
]);
return { items, total, page, pageSize };
}
async findById(id: number) {
await this.prisma.prompt.update({ where: { id }, data: { viewCount: { increment: 1 } } });
return this.prisma.prompt.findUnique({
where: { id },
include: { category: true, author: { select: { nickname: true } } },
});
}
async create(data: { title: string; content: string; description?: string; categoryId?: number; tags?: string; model?: string; authorId?: number }) {
return this.prisma.prompt.create({ data });
}
async toggleFavorite(userId: number, promptId: number) {
const existing = await this.prisma.promptFavorite.findUnique({
where: { userId_promptId: { userId, promptId } },
});
if (existing) {
await this.prisma.promptFavorite.delete({ where: { id: existing.id } });
await this.prisma.prompt.update({ where: { id: promptId }, data: { likeCount: { decrement: 1 } } });
return { favorited: false };
}
await this.prisma.promptFavorite.create({ data: { userId, promptId } });
await this.prisma.prompt.update({ where: { id: promptId }, data: { likeCount: { increment: 1 } } });
return { favorited: true };
}
async findFavorites(userId: number, params: { page?: number; pageSize?: number }) {
const page = Number(params.page ?? 1);
const pageSize = Number(params.pageSize ?? 20);
const [items, total] = await Promise.all([
this.prisma.promptFavorite.findMany({
where: { userId },
skip: (page - 1) * pageSize,
take: pageSize,
orderBy: { createdAt: 'desc' },
include: { prompt: true },
}),
this.prisma.promptFavorite.count({ where: { userId } }),
]);
return { items, total, page, pageSize };
}
}
@@ -0,0 +1,161 @@
import { Test, TestingModule } from '@nestjs/testing';
import { PrismaService } from '../../../prisma/prisma.service';
import { PromptsService } from '../prompts.service';
describe('PromptsService', () => {
let service: PromptsService;
let prisma: PrismaService;
const mockPrisma = {
prompt: {
findMany: jest.fn(),
findUnique: jest.fn(),
create: jest.fn(),
update: jest.fn(),
count: jest.fn(),
},
promptFavorite: {
findMany: jest.fn(),
findUnique: jest.fn(),
create: jest.fn(),
delete: jest.fn(),
count: jest.fn(),
},
};
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [
PromptsService,
{ provide: PrismaService, useValue: mockPrisma },
],
}).compile();
service = module.get<PromptsService>(PromptsService);
prisma = module.get<PrismaService>(PrismaService);
jest.clearAllMocks();
});
describe('findAll', () => {
it('should return paginated prompts with default params', async () => {
const mockPrompts = [
{ id: 1, title: '提示词1', status: 'PUBLISHED', deletedAt: null, isPublic: true },
];
mockPrisma.prompt.findMany.mockResolvedValue(mockPrompts);
mockPrisma.prompt.count.mockResolvedValue(1);
const result = await service.findAll({});
expect(result).toEqual({ items: mockPrompts, total: 1, page: 1, pageSize: 20 });
expect(mockPrisma.prompt.findMany).toHaveBeenCalledWith(
expect.objectContaining({
where: { status: 'PUBLISHED', deletedAt: null, isPublic: true },
skip: 0,
take: 20,
})
);
});
it('should filter by categoryId', async () => {
await service.findAll({ categoryId: 5 });
expect(mockPrisma.prompt.findMany).toHaveBeenCalledWith(
expect.objectContaining({
where: expect.objectContaining({ categoryId: 5 }),
})
);
});
it('should search by keyword', async () => {
await service.findAll({ search: 'AI' });
expect(mockPrisma.prompt.findMany).toHaveBeenCalledWith(
expect.objectContaining({
where: expect.objectContaining({
OR: [{ title: { contains: 'AI' } }, { content: { contains: 'AI' } }],
}),
})
);
});
});
describe('findById', () => {
it('should increment viewCount and return prompt', async () => {
const mockPrompt = { id: 1, title: '测试提示词', viewCount: 10 };
mockPrisma.prompt.findUnique.mockResolvedValue(mockPrompt);
const result = await service.findById(1);
expect(result).toEqual(mockPrompt);
expect(mockPrisma.prompt.update).toHaveBeenCalledWith({
where: { id: 1 },
data: { viewCount: { increment: 1 } },
});
});
});
describe('create', () => {
it('should create a new prompt', async () => {
const promptData = {
title: '新提示词',
content: '内容',
description: '描述',
categoryId: 1,
tags: 'AI,提示词',
model: 'gpt-3.5',
authorId: 1,
};
const created = { id: 1, ...promptData };
mockPrisma.prompt.create.mockResolvedValue(created);
const result = await service.create(promptData);
expect(result).toEqual(created);
expect(mockPrisma.prompt.create).toHaveBeenCalledWith({ data: promptData });
});
});
describe('toggleFavorite', () => {
it('should add favorite if not exists', async () => {
mockPrisma.promptFavorite.findUnique.mockResolvedValue(null);
mockPrisma.promptFavorite.create.mockResolvedValue({});
mockPrisma.prompt.update.mockResolvedValue({});
const result = await service.toggleFavorite(1, 1);
expect(result).toEqual({ favorited: true });
expect(mockPrisma.promptFavorite.create).toHaveBeenCalledWith({
data: { userId: 1, promptId: 1 },
});
expect(mockPrisma.prompt.update).toHaveBeenCalledWith({
where: { id: 1 },
data: { likeCount: { increment: 1 } },
});
});
it('should remove favorite if exists', async () => {
mockPrisma.promptFavorite.findUnique.mockResolvedValue({ id: 1 });
mockPrisma.promptFavorite.delete.mockResolvedValue({});
mockPrisma.prompt.update.mockResolvedValue({});
const result = await service.toggleFavorite(1, 1);
expect(result).toEqual({ favorited: false });
expect(mockPrisma.promptFavorite.delete).toHaveBeenCalledWith({
where: { id: 1 },
});
});
});
describe('findFavorites', () => {
it('should return user favorites with pagination', async () => {
const mockFavorites = [
{ id: 1, userId: 1, promptId: 1, prompt: { id: 1, title: '提示词1' } },
];
mockPrisma.promptFavorite.findMany.mockResolvedValue(mockFavorites);
mockPrisma.promptFavorite.count.mockResolvedValue(1);
const result = await service.findFavorites(1, { page: 1, pageSize: 10 });
expect(result).toEqual({ items: mockFavorites, total: 1, page: 1, pageSize: 10 });
});
});
});
@@ -0,0 +1,44 @@
import { Controller, Post, Get, Delete, Patch, Body, Param, Query, UseGuards, Req, ParseIntPipe } from '@nestjs/common';
import { ApiTags, ApiBearerAuth, ApiBody } from '@nestjs/swagger';
import { AuthGuard } from '@nestjs/passport';
import { SandboxService } from './sandbox.service';
import { ChatOptions } from '../ai/ai-gateway.service';
@ApiTags('AI沙箱')
@Controller('sandbox')
@UseGuards(AuthGuard('jwt'))
@ApiBearerAuth()
export class SandboxController {
constructor(private sandboxService: SandboxService) {}
@Post('chat')
@ApiBody({ schema: { example: { conversationId: 'uuid', model: 'general', messages: [{ role: 'user', content: 'hi' }], temperature: 0.7, top_p: 1, max_tokens: 2000 } } })
async chat(@Req() req: any, @Body() body: { conversationId?: string; model: string; messages: { role: string; content: string }[] } & ChatOptions) {
return this.sandboxService.chat(req.user.userId, body.conversationId, body.model, body.messages, body);
}
@Get('sessions')
async sessions(@Req() req: any, @Query() query: { page?: number; pageSize?: number; search?: string }) {
return this.sandboxService.getSessions(req.user.userId, query);
}
@Get('sessions/:id')
async getSession(@Req() req: any, @Param('id', ParseIntPipe) id: number) {
return this.sandboxService.getSession(req.user.userId, id);
}
@Patch('sessions/:id/feedback')
async setFeedback(@Req() req: any, @Param('id', ParseIntPipe) id: number, @Body() body: { feedback: 'LIKE' | 'DISLIKE' | null }) {
return this.sandboxService.setFeedback(req.user.userId, id, body.feedback);
}
@Delete('sessions/:id')
async deleteSession(@Req() req: any, @Param('id', ParseIntPipe) id: number) {
return this.sandboxService.deleteSession(req.user.userId, id);
}
@Get('quota')
async quota(@Req() req: any) {
return this.sandboxService.getQuota(req.user.userId);
}
}
@@ -0,0 +1,12 @@
import { Module } from '@nestjs/common';
import { SandboxController } from './sandbox.controller';
import { SandboxService } from './sandbox.service';
import { AIModule } from '../ai/ai.module';
@Module({
imports: [AIModule],
controllers: [SandboxController],
providers: [SandboxService],
exports: [SandboxService],
})
export class SandboxModule {}
@@ -0,0 +1,154 @@
import { Injectable, HttpException, HttpStatus } from '@nestjs/common';
import { PrismaService } from '../../prisma/prisma.service';
import { AIGatewayService, ChatOptions } from '../ai/ai-gateway.service';
import { randomUUID } from 'crypto';
@Injectable()
export class SandboxService {
constructor(
private prisma: PrismaService,
private aiGateway: AIGatewayService,
) {}
async chat(userId: number, conversationId: string | undefined, model: string, messages: { role: string; content: string }[], options?: ChatOptions) {
const user = await this.prisma.user.findUnique({ where: { id: userId } });
if (!user || user.status !== 'ACTIVE') {
throw new HttpException('用户不可用', HttpStatus.FORBIDDEN);
}
const convId = conversationId || randomUUID();
// 今日配额:按 conversationId 去重计数
const today = new Date();
today.setHours(0, 0, 0, 0);
const existing = await this.prisma.sandboxSession.findUnique({
where: { userId_conversationId: { userId, conversationId: convId } },
});
if (!existing) {
const todayCount = await this.prisma.sandboxSession.count({
where: { userId, createdAt: { gte: today } },
});
if (todayCount >= (user.sandboxDaily || 10)) {
throw new HttpException('今日沙箱使用次数已用完', HttpStatus.TOO_MANY_REQUESTS);
}
}
let reply = await this.aiGateway.chat(model, messages as any, options);
if (typeof reply !== 'string') {
reply = '抱歉,AI 返回了无效的回复,请重试。';
}
const allMessages = messages.concat({ role: 'assistant', content: reply });
const firstUserMsg = messages.find(m => m.role === 'user');
const title = firstUserMsg ? firstUserMsg.content.slice(0, 80) : 'AI 对话';
const session = await this.prisma.sandboxSession.upsert({
where: { userId_conversationId: { userId, conversationId: convId } },
create: {
userId,
conversationId: convId,
model,
title,
messages: JSON.stringify(allMessages),
tokens: Math.ceil(reply.length / 2),
},
update: {
model,
title,
messages: JSON.stringify(allMessages),
tokens: Math.ceil(reply.length / 2),
},
});
return { reply, conversationId: convId, sessionId: session.id };
}
async getSessions(userId: number, params: { page?: number; pageSize?: number; search?: string }) {
const page = Number(params.page ?? 1);
const pageSize = Number(params.pageSize ?? 50);
const where: any = { userId };
if (params.search) {
where.title = { contains: params.search };
}
const [items, total] = await Promise.all([
this.prisma.sandboxSession.findMany({
where,
skip: (page - 1) * pageSize,
take: pageSize,
orderBy: { createdAt: 'desc' },
select: { id: true, conversationId: true, model: true, title: true, feedback: true, createdAt: true, tokens: true },
}),
this.prisma.sandboxSession.count({ where }),
]);
return { items, total, page, pageSize };
}
async getSession(userId: number, id: number) {
const session = await this.prisma.sandboxSession.findFirst({
where: { id, userId },
});
if (!session) {
throw new HttpException('会话不存在', HttpStatus.NOT_FOUND);
}
return {
id: session.id,
conversationId: session.conversationId,
model: session.model,
title: session.title,
feedback: session.feedback,
createdAt: session.createdAt,
messages: JSON.parse(session.messages),
};
}
async setFeedback(userId: number, id: number, feedback: string | null) {
const session = await this.prisma.sandboxSession.findFirst({
where: { id, userId },
});
if (!session) {
throw new HttpException('会话不存在', HttpStatus.NOT_FOUND);
}
await this.prisma.sandboxSession.update({
where: { id },
data: { feedback: feedback || null },
});
return { success: true };
}
async deleteSession(userId: number, id: number) {
const session = await this.prisma.sandboxSession.findFirst({
where: { id, userId },
});
if (!session) {
throw new HttpException('会话不存在', HttpStatus.NOT_FOUND);
}
await this.prisma.sandboxSession.delete({ where: { id } });
return { success: true };
}
async getHistory(userId: number, params: { page?: number; pageSize?: number }) {
const page = Number(params.page ?? 1);
const pageSize = Number(params.pageSize ?? 20);
const [items, total] = await Promise.all([
this.prisma.sandboxSession.findMany({
where: { userId },
skip: (page - 1) * pageSize,
take: pageSize,
orderBy: { createdAt: 'desc' },
select: { id: true, conversationId: true, model: true, title: true, feedback: true, createdAt: true, tokens: true },
}),
this.prisma.sandboxSession.count({ where: { userId } }),
]);
return { items, total, page, pageSize };
}
async getQuota(userId: number) {
const user = await this.prisma.user.findUnique({ where: { id: userId } });
const today = new Date();
today.setHours(0, 0, 0, 0);
const used = await this.prisma.sandboxSession.count({
where: { userId, createdAt: { gte: today } },
});
return { dailyLimit: user?.sandboxDaily || 10, used, remaining: (user?.sandboxDaily || 10) - used };
}
}
@@ -0,0 +1,145 @@
import { Test, TestingModule } from '@nestjs/testing';
import { HttpException, HttpStatus } from '@nestjs/common';
import { SandboxService } from '../sandbox.service';
import { PrismaService } from '../../../prisma/prisma.service';
import { AIGatewayService } from '../../ai/ai-gateway.service';
describe('SandboxService', () => {
let service: SandboxService;
let prisma: PrismaService;
let aiGateway: AIGatewayService;
const mockPrisma = {
user: {
findUnique: jest.fn(),
},
sandboxSession: {
findMany: jest.fn(),
findUnique: jest.fn(),
count: jest.fn(),
create: jest.fn(),
upsert: jest.fn(),
},
};
const mockAIGateway = {
chat: jest.fn(),
};
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [
SandboxService,
{ provide: PrismaService, useValue: mockPrisma },
{ provide: AIGatewayService, useValue: mockAIGateway },
],
}).compile();
service = module.get<SandboxService>(SandboxService);
prisma = module.get<PrismaService>(PrismaService);
aiGateway = module.get<AIGatewayService>(AIGatewayService);
jest.clearAllMocks();
});
describe('chat', () => {
it('should throw if user not found', async () => {
mockPrisma.user.findUnique.mockResolvedValue(null);
await expect(
service.chat(1, 'test-conv', 'gpt-3.5', [{ role: 'user', content: 'Hello' }])
).rejects.toThrow(HttpException);
});
it('should throw if user not active', async () => {
mockPrisma.user.findUnique.mockResolvedValue({ id: 1, status: 'INACTIVE' });
await expect(
service.chat(1, 'test-conv', 'gpt-3.5', [{ role: 'user', content: 'Hello' }])
).rejects.toThrow(HttpException);
});
it('should throw if daily quota exceeded for new conversation', async () => {
mockPrisma.user.findUnique.mockResolvedValue({
id: 1, status: 'ACTIVE', sandboxDaily: 10
});
mockPrisma.sandboxSession.findUnique.mockResolvedValue(null);
mockPrisma.sandboxSession.count.mockResolvedValue(10);
await expect(
service.chat(1, 'new-conv', 'gpt-3.5', [{ role: 'user', content: 'Hello' }])
).rejects.toThrow(HttpException);
});
it('should not count quota for existing conversation', async () => {
mockPrisma.user.findUnique.mockResolvedValue({
id: 1, status: 'ACTIVE', sandboxDaily: 10
});
mockPrisma.sandboxSession.findUnique.mockResolvedValue({ id: 1 });
mockAIGateway.chat.mockResolvedValue('AI回复');
mockPrisma.sandboxSession.upsert.mockResolvedValue({ id: 2 });
const result = await service.chat(1, 'existing-conv', 'gpt-3.5', [
{ role: 'user', content: 'Hello' }
]);
expect(result.reply).toBe('AI回复');
expect(mockPrisma.sandboxSession.count).not.toHaveBeenCalled();
});
it('should call AI gateway and save session', async () => {
mockPrisma.user.findUnique.mockResolvedValue({
id: 1, status: 'ACTIVE', sandboxDaily: 10
});
mockPrisma.sandboxSession.findUnique.mockResolvedValue(null);
mockPrisma.sandboxSession.count.mockResolvedValue(5);
mockAIGateway.chat.mockResolvedValue('AI回复');
mockPrisma.sandboxSession.upsert.mockResolvedValue({ id: 1 });
const result = await service.chat(1, 'test-conv', 'gpt-3.5', [
{ role: 'user', content: 'Hello' }
]);
expect(result.reply).toBe('AI回复');
expect(result.conversationId).toBe('test-conv');
expect(result.sessionId).toBeDefined();
expect(mockAIGateway.chat).toHaveBeenCalledWith('gpt-3.5', expect.any(Array), undefined);
expect(mockPrisma.sandboxSession.upsert).toHaveBeenCalled();
});
});
describe('getHistory', () => {
it('should return paginated history', async () => {
const mockSessions = [
{ id: 1, conversationId: 'conv-1', model: 'gpt-3.5', title: 'Test', createdAt: new Date(), tokens: 10 }
];
mockPrisma.sandboxSession.findMany.mockResolvedValue(mockSessions);
mockPrisma.sandboxSession.count.mockResolvedValue(1);
const result = await service.getHistory(1, { page: 1, pageSize: 20 });
expect(result).toEqual({
items: mockSessions,
total: 1,
page: 1,
pageSize: 20,
});
});
});
describe('getQuota', () => {
it('should return quota info', async () => {
mockPrisma.user.findUnique.mockResolvedValue({
id: 1, sandboxDaily: 10
});
mockPrisma.sandboxSession.count.mockResolvedValue(3);
const result = await service.getQuota(1);
expect(result).toEqual({
dailyLimit: 10,
used: 3,
remaining: 7,
});
});
});
});
@@ -0,0 +1,24 @@
import { Controller, Get, Query } from '@nestjs/common';
import { ApiTags } from '@nestjs/swagger';
import { SearchService } from './search.service';
@ApiTags('搜索')
@Controller('search')
export class SearchController {
constructor(private searchService: SearchService) {}
@Get()
async search(
@Query('q') q: string,
@Query('type') type?: string,
@Query('page') page?: string,
@Query('pageSize') pageSize?: string,
) {
return this.searchService.search({
q,
type,
page: page ? parseInt(page, 10) : undefined,
pageSize: pageSize ? parseInt(pageSize, 10) : undefined,
});
}
}
@@ -0,0 +1,9 @@
import { Module } from '@nestjs/common';
import { SearchController } from './search.controller';
import { SearchService } from './search.service';
@Module({
controllers: [SearchController],
providers: [SearchService],
})
export class SearchModule {}
@@ -0,0 +1,139 @@
import { Injectable } from '@nestjs/common';
import { PrismaService } from '../../prisma/prisma.service';
@Injectable()
export class SearchService {
constructor(private prisma: PrismaService) {}
async search(params: { q: string; type?: string; page?: number; pageSize?: number }) {
const q = params.q?.trim();
if (!q) return { results: [], total: 0 };
const page = Number(params.page ?? 1);
const pageSize = Math.min(Number(params.pageSize ?? 10), 50);
const skip = (page - 1) * pageSize;
const type = params.type || 'all';
const results: any[] = [];
let total = 0;
const where: any = {
AND: [
{ deletedAt: null },
{ status: 'PUBLISHED' },
{
OR: [
{ title: { contains: q } },
{ description: { contains: q } },
],
},
],
};
if (type === 'all' || type === 'courses') {
const [items, count] = await Promise.all([
this.prisma.course.findMany({
where: {
AND: [
{ deletedAt: null },
{ status: 'PUBLISHED' },
{
OR: [
{ title: { contains: q } },
{ description: { contains: q } },
],
},
],
},
select: { id: true, title: true, description: true, cover: true, isFree: true },
skip: type === 'courses' ? skip : 0,
take: type === 'courses' ? pageSize : 5,
}),
this.prisma.course.count({ where }),
]);
results.push(...items.map(i => ({ ...i, _type: 'course' })));
total += count;
}
if (type === 'all' || type === 'prompts') {
const promptWhere: any = {
AND: [
{ deletedAt: null },
{ status: 'PUBLISHED' },
{
OR: [
{ title: { contains: q } },
{ description: { contains: q } },
{ content: { contains: q } },
],
},
],
};
const [items, count] = await Promise.all([
this.prisma.prompt.findMany({
where: promptWhere,
select: { id: true, title: true, description: true, model: true },
skip: type === 'prompts' ? skip : 0,
take: type === 'prompts' ? pageSize : 5,
}),
this.prisma.prompt.count({ where: promptWhere }),
]);
results.push(...items.map(i => ({ ...i, _type: 'prompt' })));
total += count;
}
if (type === 'all' || type === 'tools') {
const toolWhere: any = {
AND: [
{ deletedAt: null },
{ status: 'PUBLISHED' },
{
OR: [
{ name: { contains: q } },
{ description: { contains: q } },
],
},
],
};
const [items, count] = await Promise.all([
this.prisma.tool.findMany({
where: toolWhere,
select: { id: true, name: true, description: true, url: true, icon: true },
skip: type === 'tools' ? skip : 0,
take: type === 'tools' ? pageSize : 5,
}),
this.prisma.tool.count({ where: toolWhere }),
]);
results.push(...items.map(i => ({ ...i, _type: 'tool' })));
total += count;
}
if (type === 'all' || type === 'contents') {
const contentWhere: any = {
AND: [
{ deletedAt: null },
{ status: 'PUBLISHED' },
{
OR: [
{ title: { contains: q } },
{ summary: { contains: q } },
],
},
],
};
const [items, count] = await Promise.all([
this.prisma.content.findMany({
where: contentWhere,
select: { id: true, title: true, summary: true, cover: true, publishedAt: true },
skip: type === 'contents' ? skip : 0,
take: type === 'contents' ? pageSize : 5,
}),
this.prisma.content.count({ where: contentWhere }),
]);
results.push(...items.map(i => ({ ...i, _type: 'content' })));
total += count;
}
return { results, total, page, pageSize };
}
}
@@ -0,0 +1,89 @@
import { Test, TestingModule } from '@nestjs/testing';
import { SearchService } from '../search.service';
import { PrismaService } from '../../../prisma/prisma.service';
describe('SearchService', () => {
let service: SearchService;
let prisma: PrismaService;
const mockPrisma = {
course: { findMany: jest.fn(), count: jest.fn() },
prompt: { findMany: jest.fn(), count: jest.fn() },
tool: { findMany: jest.fn(), count: jest.fn() },
content: { findMany: jest.fn(), count: jest.fn() },
};
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [
SearchService,
{ provide: PrismaService, useValue: mockPrisma },
],
}).compile();
service = module.get<SearchService>(SearchService);
prisma = module.get<PrismaService>(PrismaService);
jest.clearAllMocks();
});
describe('search', () => {
it('should return empty results for empty query', async () => {
const result = await service.search({ q: '' });
expect(result).toEqual({ results: [], total: 0 });
});
it('should search across all types by default', async () => {
mockPrisma.course.findMany.mockResolvedValue([{ id: 1, title: 'AI课程', _type: 'course' }]);
mockPrisma.course.count.mockResolvedValue(1);
mockPrisma.prompt.findMany.mockResolvedValue([]);
mockPrisma.prompt.count.mockResolvedValue(0);
mockPrisma.tool.findMany.mockResolvedValue([]);
mockPrisma.tool.count.mockResolvedValue(0);
mockPrisma.content.findMany.mockResolvedValue([]);
mockPrisma.content.count.mockResolvedValue(0);
const result = await service.search({ q: 'AI' });
expect(result.results).toHaveLength(1);
expect(result.results[0]).toHaveProperty('_type', 'course');
expect(result.total).toBe(1);
});
it('should filter by specific type', async () => {
mockPrisma.tool.findMany.mockResolvedValue([{ id: 1, name: 'ChatGPT', _type: 'tool' }]);
mockPrisma.tool.count.mockResolvedValue(1);
const result = await service.search({ q: 'chat', type: 'tools' });
expect(result.results).toHaveLength(1);
expect(result.results[0]).toHaveProperty('_type', 'tool');
});
it('should respect pagination params', async () => {
mockPrisma.course.findMany.mockResolvedValue([]);
mockPrisma.course.count.mockResolvedValue(0);
mockPrisma.prompt.findMany.mockResolvedValue([]);
mockPrisma.prompt.count.mockResolvedValue(0);
mockPrisma.tool.findMany.mockResolvedValue([]);
mockPrisma.tool.count.mockResolvedValue(0);
mockPrisma.content.findMany.mockResolvedValue([]);
mockPrisma.content.count.mockResolvedValue(0);
const result = await service.search({ q: 'AI', page: 2, pageSize: 5 });
expect(result.page).toBe(2);
expect(result.pageSize).toBe(5);
});
it('should cap pageSize at 50', async () => {
mockPrisma.course.findMany.mockResolvedValue([]);
mockPrisma.course.count.mockResolvedValue(0);
mockPrisma.prompt.findMany.mockResolvedValue([]);
mockPrisma.prompt.count.mockResolvedValue(0);
mockPrisma.tool.findMany.mockResolvedValue([]);
mockPrisma.tool.count.mockResolvedValue(0);
mockPrisma.content.findMany.mockResolvedValue([]);
mockPrisma.content.count.mockResolvedValue(0);
const result = await service.search({ q: 'AI', pageSize: 100 });
expect(result.pageSize).toBe(50);
});
});
});
@@ -0,0 +1,78 @@
import { Test, TestingModule } from '@nestjs/testing';
import { ToolsService } from '../tools.service';
import { PrismaService } from '../../../prisma/prisma.service';
describe('ToolsService', () => {
let service: ToolsService;
let prisma: PrismaService;
const mockPrisma = {
tool: {
findMany: jest.fn(),
count: jest.fn(),
create: jest.fn(),
},
};
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [
ToolsService,
{ provide: PrismaService, useValue: mockPrisma },
],
}).compile();
service = module.get<ToolsService>(ToolsService);
prisma = module.get<PrismaService>(PrismaService);
jest.clearAllMocks();
});
describe('findAll', () => {
it('should return paginated tools', async () => {
const mockTools = [{ id: 1, name: 'ChatGPT', category: { id: 1, name: '聊天' } }];
mockPrisma.tool.findMany.mockResolvedValue(mockTools);
mockPrisma.tool.count.mockResolvedValue(1);
const result = await service.findAll({ page: 1, pageSize: 20 });
expect(result.items).toEqual(mockTools);
expect(result.total).toBe(1);
expect(result.page).toBe(1);
expect(result.pageSize).toBe(20);
});
it('should filter by categoryId', async () => {
mockPrisma.tool.findMany.mockResolvedValue([]);
mockPrisma.tool.count.mockResolvedValue(0);
await service.findAll({ categoryId: 2 });
expect(mockPrisma.tool.findMany).toHaveBeenCalledWith(
expect.objectContaining({
where: expect.objectContaining({ categoryId: 2 }),
}),
);
});
it('should filter by isFeatured', async () => {
mockPrisma.tool.findMany.mockResolvedValue([]);
mockPrisma.tool.count.mockResolvedValue(0);
await service.findAll({ isFeatured: true });
expect(mockPrisma.tool.findMany).toHaveBeenCalledWith(
expect.objectContaining({
where: expect.objectContaining({ isFeatured: true }),
}),
);
});
});
describe('create', () => {
it('should create a tool', async () => {
const data = { name: 'New Tool', url: 'https://example.com' };
mockPrisma.tool.create.mockResolvedValue({ id: 1, ...data });
const result = await service.create(data);
expect(result).toHaveProperty('id', 1);
expect(mockPrisma.tool.create).toHaveBeenCalledWith({ data });
});
});
});
@@ -0,0 +1,19 @@
import { Controller, Get, Post, Body, Query } from '@nestjs/common';
import { ApiTags } from '@nestjs/swagger';
import { ToolsService } from './tools.service';
@ApiTags('AI工具')
@Controller('tools')
export class ToolsController {
constructor(private toolsService: ToolsService) {}
@Get()
async findAll(@Query() query: { page?: number; pageSize?: number; categoryId?: number; isFeatured?: boolean }) {
return this.toolsService.findAll(query);
}
@Post()
async create(@Body() body: { name: string; description?: string; url: string; icon?: string; categoryId?: number; tags?: string }) {
return this.toolsService.create(body);
}
}
+10
View File
@@ -0,0 +1,10 @@
import { Module } from '@nestjs/common';
import { ToolsController } from './tools.controller';
import { ToolsService } from './tools.service';
@Module({
controllers: [ToolsController],
providers: [ToolsService],
exports: [ToolsService],
})
export class ToolsModule {}
@@ -0,0 +1,33 @@
import { Injectable } from '@nestjs/common';
import { PrismaService } from '../../prisma/prisma.service';
@Injectable()
export class ToolsService {
constructor(private prisma: PrismaService) {}
async findAll(params: { page?: number; pageSize?: number; categoryId?: number; isFeatured?: boolean }) {
const page = Number(params.page ?? 1);
const pageSize = Number(params.pageSize ?? 20);
const { categoryId, isFeatured } = params;
const where: any = { status: 'PUBLISHED', deletedAt: null };
if (categoryId) where.categoryId = categoryId;
if (isFeatured !== undefined) where.isFeatured = isFeatured;
const [items, total] = await Promise.all([
this.prisma.tool.findMany({
where,
skip: (page - 1) * pageSize,
take: pageSize,
orderBy: { viewCount: 'desc' },
include: { category: true },
}),
this.prisma.tool.count({ where }),
]);
return { items, total, page, pageSize };
}
async create(data: { name: string; description?: string; url: string; icon?: string; categoryId?: number; tags?: string }) {
return this.prisma.tool.create({ data });
}
}
@@ -0,0 +1,52 @@
import {
Controller,
Post,
UseInterceptors,
UploadedFile,
BadRequestException,
} from '@nestjs/common';
import { FileInterceptor } from '@nestjs/platform-express';
import { diskStorage } from 'multer';
import { extname, join } from 'path';
import { randomUUID } from 'crypto';
import { ApiTags } from '@nestjs/swagger';
const ALLOWED_TYPES = ['image/jpeg', 'image/png', 'image/gif', 'image/webp', 'image/svg+xml'];
const MAX_SIZE = 5 * 1024 * 1024;
@ApiTags('文件上传')
@Controller('upload')
export class UploadController {
@Post()
@UseInterceptors(
FileInterceptor('file', {
storage: diskStorage({
destination: join(process.cwd(), 'uploads'),
filename: (_req, file, cb) => {
const ext = extname(file.originalname);
cb(null, `${randomUUID()}${ext}`);
},
}),
limits: { fileSize: MAX_SIZE },
fileFilter: (_req, file, cb) => {
if (ALLOWED_TYPES.includes(file.mimetype)) {
cb(null, true);
} else {
cb(new BadRequestException('不支持的文件类型,仅支持 jpg/png/gif/webp/svg'), false);
}
},
}),
)
uploadFile(@UploadedFile() file: Express.Multer.File) {
if (!file) {
throw new BadRequestException('请选择文件');
}
return {
url: `/uploads/${file.filename}`,
filename: file.filename,
size: file.size,
mimetype: file.mimetype,
};
}
}
@@ -0,0 +1,9 @@
import { Module } from '@nestjs/common';
import { UploadController } from './upload.controller';
import { UploadService } from './upload.service';
@Module({
controllers: [UploadController],
providers: [UploadService],
})
export class UploadModule {}
@@ -0,0 +1,18 @@
import { Injectable } from '@nestjs/common';
import { extname } from 'path';
import * as fs from 'fs';
@Injectable()
export class UploadService {
private uploadDir = 'uploads';
ensureUploadDir() {
if (!fs.existsSync(this.uploadDir)) {
fs.mkdirSync(this.uploadDir, { recursive: true });
}
}
getUploadDir(): string {
return this.uploadDir;
}
}
@@ -0,0 +1,83 @@
import { Test, TestingModule } from '@nestjs/testing';
import { UsersService } from '../users.service';
import { PrismaService } from '../../../prisma/prisma.service';
describe('UsersService', () => {
let service: UsersService;
let prisma: PrismaService;
const mockPrisma = {
user: {
findMany: jest.fn(),
findUnique: jest.fn(),
count: jest.fn(),
update: jest.fn(),
},
};
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [
UsersService,
{ provide: PrismaService, useValue: mockPrisma },
],
}).compile();
service = module.get<UsersService>(UsersService);
prisma = module.get<PrismaService>(PrismaService);
jest.clearAllMocks();
});
describe('findAll', () => {
it('should return paginated users', async () => {
const mockUsers = [{ id: 1, nickname: '用户1' }];
mockPrisma.user.findMany.mockResolvedValue(mockUsers);
mockPrisma.user.count.mockResolvedValue(1);
const result = await service.findAll({ page: 1, pageSize: 20 });
expect(result.items).toEqual(mockUsers);
expect(result.total).toBe(1);
});
it('should filter by status', async () => {
mockPrisma.user.findMany.mockResolvedValue([]);
mockPrisma.user.count.mockResolvedValue(0);
await service.findAll({ status: 'ACTIVE' });
expect(mockPrisma.user.findMany).toHaveBeenCalledWith(
expect.objectContaining({
where: expect.objectContaining({ status: 'ACTIVE' }),
}),
);
});
});
describe('findById', () => {
it('should return user by id', async () => {
const mockUser = { id: 1, nickname: '用户1' };
mockPrisma.user.findUnique.mockResolvedValue(mockUser);
const result = await service.findById(1);
expect(result).toEqual(mockUser);
});
it('should return null for non-existent user', async () => {
mockPrisma.user.findUnique.mockResolvedValue(null);
const result = await service.findById(999);
expect(result).toBeNull();
});
});
describe('updateProfile', () => {
it('should update user profile', async () => {
mockPrisma.user.update.mockResolvedValue({ id: 1, nickname: '新昵称' });
const result = await service.updateProfile(1, { nickname: '新昵称' });
expect(result).toHaveProperty('nickname', '新昵称');
expect(mockPrisma.user.update).toHaveBeenCalledWith({
where: { id: 1 },
data: { nickname: '新昵称' },
});
});
});
});
@@ -0,0 +1,31 @@
import { Controller, Get, Put, Body, Param, UseGuards, Req, Query } from '@nestjs/common';
import { ApiTags, ApiBearerAuth } from '@nestjs/swagger';
import { AuthGuard } from '@nestjs/passport';
import { UsersService } from './users.service';
@ApiTags('用户')
@Controller('users')
export class UsersController {
constructor(private usersService: UsersService) {}
@Get()
@UseGuards(AuthGuard('jwt'))
@ApiBearerAuth()
async findAll(@Query() query: { page?: number; pageSize?: number; status?: string }) {
return this.usersService.findAll(query);
}
@Get(':id')
@UseGuards(AuthGuard('jwt'))
@ApiBearerAuth()
async findById(@Param('id') id: string) {
return this.usersService.findById(+id);
}
@Put('profile')
@UseGuards(AuthGuard('jwt'))
@ApiBearerAuth()
async updateProfile(@Req() req: any, @Body() body: { nickname?: string; avatar?: string }) {
return this.usersService.updateProfile(req.user.userId, body);
}
}
+10
View File
@@ -0,0 +1,10 @@
import { Module } from '@nestjs/common';
import { UsersController } from './users.controller';
import { UsersService } from './users.service';
@Module({
controllers: [UsersController],
providers: [UsersService],
exports: [UsersService],
})
export class UsersModule {}
@@ -0,0 +1,45 @@
import { Injectable } from '@nestjs/common';
import { PrismaService } from '../../prisma/prisma.service';
@Injectable()
export class UsersService {
constructor(private prisma: PrismaService) {}
async findAll(params: { page?: number; pageSize?: number; status?: string }) {
const page = Number(params.page ?? 1);
const pageSize = Number(params.pageSize ?? 20);
const where: any = { deletedAt: null };
if (params.status) where.status = params.status;
const [items, total] = await Promise.all([
this.prisma.user.findMany({
where,
skip: (page - 1) * pageSize,
take: pageSize,
orderBy: { createdAt: 'desc' },
select: {
id: true, nickname: true, avatar: true, phone: true, email: true,
status: true, memberPlan: true, createdAt: true, lastLoginAt: true,
},
}),
this.prisma.user.count({ where }),
]);
return { items, total, page, pageSize };
}
async findById(id: number) {
return this.prisma.user.findUnique({
where: { id },
select: {
id: true, nickname: true, avatar: true, phone: true, email: true,
status: true, memberPlan: true, memberExpire: true, sandboxDaily: true,
createdAt: true, lastLoginAt: true,
},
});
}
async updateProfile(id: number, data: { nickname?: string; avatar?: string }) {
return this.prisma.user.update({ where: { id }, data });
}
}

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