feat: Phase 1-3 全部完成 — 沙盒增强、学情分析、学习路径
This commit is contained in:
@@ -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
@@ -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/
|
||||||
@@ -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 + PostgreSQL(schema 在 `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`。
|
||||||
@@ -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/ # 后端 API(NestJS + 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 实操沙箱体验
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
.next
|
||||||
|
.env
|
||||||
|
.env.local
|
||||||
|
*.log
|
||||||
|
.git
|
||||||
|
.gitignore
|
||||||
|
.DS_Store
|
||||||
|
coverage
|
||||||
@@ -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=
|
||||||
@@ -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"]
|
||||||
@@ -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>
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://json.schemastore.org/nest-cli",
|
||||||
|
"collection": "@nestjs/schematics",
|
||||||
|
"sourceRoot": "src",
|
||||||
|
"compilerOptions": {
|
||||||
|
"deleteOutDir": true
|
||||||
|
}
|
||||||
|
}
|
||||||
Generated
+10441
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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")
|
||||||
|
}
|
||||||
Vendored
+1
@@ -0,0 +1 @@
|
|||||||
|
export {};
|
||||||
@@ -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
|
||||||
@@ -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"}
|
||||||
@@ -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();
|
||||||
|
});
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
{
|
||||||
|
"extends": "./tsconfig.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"rootDir": ".",
|
||||||
|
"outDir": "./dist-prisma",
|
||||||
|
"types": ["node"]
|
||||||
|
},
|
||||||
|
"include": ["prisma/**/*.ts"]
|
||||||
|
}
|
||||||
@@ -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(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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`);
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 };
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 {}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
Reference in New Issue
Block a user