初始化参股
This commit is contained in:
33
后端/.env.example
Normal file
33
后端/.env.example
Normal file
@@ -0,0 +1,33 @@
|
||||
# 数据库配置示例
|
||||
DB_HOST=localhost
|
||||
DB_PORT=3306
|
||||
DB_USER=your_username
|
||||
DB_PASS=your_password
|
||||
DB_NAME=youyijia_sofa
|
||||
|
||||
# JWT 配置示例
|
||||
JWT_SECRET=your-super-secret-jwt-key
|
||||
JWT_EXPIRES_IN=7d
|
||||
JWT_REFRESH_SECRET=your-refresh-secret
|
||||
JWT_REFRESH_EXPIRES_IN=30d
|
||||
|
||||
# 服务器配置示例
|
||||
PORT=3000
|
||||
NODE_ENV=development
|
||||
|
||||
# 文件上传配置示例
|
||||
UPLOAD_DIR=uploads
|
||||
MAX_FILE_SIZE=5242880
|
||||
|
||||
# 跨域配置示例
|
||||
FRONTEND_URL=http://localhost:8080
|
||||
|
||||
# 微信小程序配置
|
||||
WECHAT_APPID=your_wechat_appid
|
||||
WECHAT_SECRET=your_wechat_secret
|
||||
|
||||
# Redis 配置示例 (可选)
|
||||
REDIS_HOST=localhost
|
||||
REDIS_PORT=6379
|
||||
REDIS_PASSWORD=
|
||||
REDIS_DB=0
|
||||
62
后端/.github/copilot-instructions.md
vendored
Normal file
62
后端/.github/copilot-instructions.md
vendored
Normal file
@@ -0,0 +1,62 @@
|
||||
<!-- NestJS 沙发翻新小程序后端项目说明 -->
|
||||
|
||||
## 项目概述
|
||||
基于 Node.js + NestJS + TypeORM 构建的沙发翻新服务小程序后端系统,支持用户认证、案例管理、服务管理等核心功能。
|
||||
|
||||
## 技术栈
|
||||
- **框架**: NestJS v11.x
|
||||
- **数据库**: MySQL + TypeORM v0.3.x
|
||||
- **认证**: JWT (JSON Web Token)
|
||||
- **验证**: class-validator + class-transformer
|
||||
- **文档**: Swagger/OpenAPI
|
||||
- **语言**: TypeScript
|
||||
|
||||
## 开发规范
|
||||
|
||||
### 代码结构
|
||||
- 采用模块化架构,每个业务功能独立为模块
|
||||
- DTO 用于数据传输验证
|
||||
- Entity 定义数据库实体关系
|
||||
- Service 实现业务逻辑
|
||||
- Controller 处理路由和 HTTP 请求
|
||||
|
||||
### 权限控制
|
||||
- 使用 `@Public()` 装饰器标记公开接口
|
||||
- 使用 `@Roles()` 装饰器进行角色权限控制
|
||||
- 使用 `@CurrentUser()` 获取当前登录用户信息
|
||||
|
||||
### API 设计原则
|
||||
- 遵循 RESTful API 设计规范
|
||||
- 统一错误处理和响应格式
|
||||
- 完整的 Swagger 文档注释
|
||||
- 输入验证和数据转换
|
||||
|
||||
## 数据库设计
|
||||
- Users: 用户表,支持多角色 (admin/customer/worker)
|
||||
- Cases: 案例表,展示翻新前后对比
|
||||
- Services: 服务表,管理服务项目和价格
|
||||
- Bookings: 预约表,处理客户预约和订单
|
||||
|
||||
## 开发流程
|
||||
1. 使用 `npm run start:dev` 启动开发服务器
|
||||
2. 访问 http://localhost:3000/docs 查看 API 文档
|
||||
3. 确保 MySQL 数据库连接配置正确
|
||||
4. TypeORM 会自动同步数据库结构 (开发环境)
|
||||
|
||||
### ✅ 已完成功能
|
||||
- [x] TypeORM 数据库配置和连接
|
||||
- [x] JWT 认证系统 (注册/登录/刷新令牌)
|
||||
- [x] 用户管理模块 (个人资料/权限控制)
|
||||
- [x] 案例管理模块 (CRUD/点赞/筛选)
|
||||
- [x] 服务管理模块 (价格/状态/排序)
|
||||
- [x] Swagger API 文档集成
|
||||
- [x] 全局验证管道和错误处理
|
||||
- [x] CORS 跨域配置
|
||||
|
||||
### 🚧 待开发功能
|
||||
- [ ] 预约管理模块 (Booking)
|
||||
- [ ] 文件上传功能 (图片存储)
|
||||
- [ ] 数据库迁移脚本
|
||||
- [ ] 单元测试和 E2E 测试
|
||||
- [ ] 日志系统
|
||||
- [ ] 性能监控和限流
|
||||
56
后端/.gitignore
vendored
Normal file
56
后端/.gitignore
vendored
Normal file
@@ -0,0 +1,56 @@
|
||||
# compiled output
|
||||
/dist
|
||||
/node_modules
|
||||
/build
|
||||
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
pnpm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
lerna-debug.log*
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
|
||||
# Tests
|
||||
/coverage
|
||||
/.nyc_output
|
||||
|
||||
# IDEs and editors
|
||||
/.idea
|
||||
.project
|
||||
.classpath
|
||||
.c9/
|
||||
*.launch
|
||||
.settings/
|
||||
*.sublime-workspace
|
||||
|
||||
# IDE - VSCode
|
||||
.vscode/*
|
||||
!.vscode/settings.json
|
||||
!.vscode/tasks.json
|
||||
!.vscode/launch.json
|
||||
!.vscode/extensions.json
|
||||
|
||||
# dotenv environment variable files
|
||||
.env
|
||||
.env.development.local
|
||||
.env.test.local
|
||||
.env.production.local
|
||||
.env.local
|
||||
|
||||
# temp directory
|
||||
.temp
|
||||
.tmp
|
||||
|
||||
# Runtime data
|
||||
pids
|
||||
*.pid
|
||||
*.seed
|
||||
*.pid.lock
|
||||
|
||||
# Diagnostic reports (https://nodejs.org/api/report.html)
|
||||
report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
|
||||
4
后端/.prettierrc
Normal file
4
后端/.prettierrc
Normal file
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"singleQuote": true,
|
||||
"trailingComma": "all"
|
||||
}
|
||||
98
后端/README-original.md
Normal file
98
后端/README-original.md
Normal file
@@ -0,0 +1,98 @@
|
||||
<p align="center">
|
||||
<a href="http://nestjs.com/" target="blank"><img src="https://nestjs.com/img/logo-small.svg" width="120" alt="Nest Logo" /></a>
|
||||
</p>
|
||||
|
||||
[circleci-image]: https://img.shields.io/circleci/build/github/nestjs/nest/master?token=abc123def456
|
||||
[circleci-url]: https://circleci.com/gh/nestjs/nest
|
||||
|
||||
<p align="center">A progressive <a href="http://nodejs.org" target="_blank">Node.js</a> framework for building efficient and scalable server-side applications.</p>
|
||||
<p align="center">
|
||||
<a href="https://www.npmjs.com/~nestjscore" target="_blank"><img src="https://img.shields.io/npm/v/@nestjs/core.svg" alt="NPM Version" /></a>
|
||||
<a href="https://www.npmjs.com/~nestjscore" target="_blank"><img src="https://img.shields.io/npm/l/@nestjs/core.svg" alt="Package License" /></a>
|
||||
<a href="https://www.npmjs.com/~nestjscore" target="_blank"><img src="https://img.shields.io/npm/dm/@nestjs/common.svg" alt="NPM Downloads" /></a>
|
||||
<a href="https://circleci.com/gh/nestjs/nest" target="_blank"><img src="https://img.shields.io/circleci/build/github/nestjs/nest/master" alt="CircleCI" /></a>
|
||||
<a href="https://discord.gg/G7Qnnhy" target="_blank"><img src="https://img.shields.io/badge/discord-online-brightgreen.svg" alt="Discord"/></a>
|
||||
<a href="https://opencollective.com/nest#backer" target="_blank"><img src="https://opencollective.com/nest/backers/badge.svg" alt="Backers on Open Collective" /></a>
|
||||
<a href="https://opencollective.com/nest#sponsor" target="_blank"><img src="https://opencollective.com/nest/sponsors/badge.svg" alt="Sponsors on Open Collective" /></a>
|
||||
<a href="https://paypal.me/kamilmysliwiec" target="_blank"><img src="https://img.shields.io/badge/Donate-PayPal-ff3f59.svg" alt="Donate us"/></a>
|
||||
<a href="https://opencollective.com/nest#sponsor" target="_blank"><img src="https://img.shields.io/badge/Support%20us-Open%20Collective-41B883.svg" alt="Support us"></a>
|
||||
<a href="https://twitter.com/nestframework" target="_blank"><img src="https://img.shields.io/twitter/follow/nestframework.svg?style=social&label=Follow" alt="Follow us on Twitter"></a>
|
||||
</p>
|
||||
<!--[](https://opencollective.com/nest#backer)
|
||||
[](https://opencollective.com/nest#sponsor)-->
|
||||
|
||||
## Description
|
||||
|
||||
[Nest](https://github.com/nestjs/nest) framework TypeScript starter repository.
|
||||
|
||||
## Project setup
|
||||
|
||||
```bash
|
||||
$ npm install
|
||||
```
|
||||
|
||||
## Compile and run the project
|
||||
|
||||
```bash
|
||||
# development
|
||||
$ npm run start
|
||||
|
||||
# watch mode
|
||||
$ npm run start:dev
|
||||
|
||||
# production mode
|
||||
$ npm run start:prod
|
||||
```
|
||||
|
||||
## Run tests
|
||||
|
||||
```bash
|
||||
# unit tests
|
||||
$ npm run test
|
||||
|
||||
# e2e tests
|
||||
$ npm run test:e2e
|
||||
|
||||
# test coverage
|
||||
$ npm run test:cov
|
||||
```
|
||||
|
||||
## Deployment
|
||||
|
||||
When you're ready to deploy your NestJS application to production, there are some key steps you can take to ensure it runs as efficiently as possible. Check out the [deployment documentation](https://docs.nestjs.com/deployment) for more information.
|
||||
|
||||
If you are looking for a cloud-based platform to deploy your NestJS application, check out [Mau](https://mau.nestjs.com), our official platform for deploying NestJS applications on AWS. Mau makes deployment straightforward and fast, requiring just a few simple steps:
|
||||
|
||||
```bash
|
||||
$ npm install -g @nestjs/mau
|
||||
$ mau deploy
|
||||
```
|
||||
|
||||
With Mau, you can deploy your application in just a few clicks, allowing you to focus on building features rather than managing infrastructure.
|
||||
|
||||
## Resources
|
||||
|
||||
Check out a few resources that may come in handy when working with NestJS:
|
||||
|
||||
- Visit the [NestJS Documentation](https://docs.nestjs.com) to learn more about the framework.
|
||||
- For questions and support, please visit our [Discord channel](https://discord.gg/G7Qnnhy).
|
||||
- To dive deeper and get more hands-on experience, check out our official video [courses](https://courses.nestjs.com/).
|
||||
- Deploy your application to AWS with the help of [NestJS Mau](https://mau.nestjs.com) in just a few clicks.
|
||||
- Visualize your application graph and interact with the NestJS application in real-time using [NestJS Devtools](https://devtools.nestjs.com).
|
||||
- Need help with your project (part-time to full-time)? Check out our official [enterprise support](https://enterprise.nestjs.com).
|
||||
- To stay in the loop and get updates, follow us on [X](https://x.com/nestframework) and [LinkedIn](https://linkedin.com/company/nestjs).
|
||||
- Looking for a job, or have a job to offer? Check out our official [Jobs board](https://jobs.nestjs.com).
|
||||
|
||||
## Support
|
||||
|
||||
Nest is an MIT-licensed open source project. It can grow thanks to the sponsors and support by the amazing backers. If you'd like to join them, please [read more here](https://docs.nestjs.com/support).
|
||||
|
||||
## Stay in touch
|
||||
|
||||
- Author - [Kamil Myśliwiec](https://twitter.com/kammysliwiec)
|
||||
- Website - [https://nestjs.com](https://nestjs.com/)
|
||||
- Twitter - [@nestframework](https://twitter.com/nestframework)
|
||||
|
||||
## License
|
||||
|
||||
Nest is [MIT licensed](https://github.com/nestjs/nest/blob/master/LICENSE).
|
||||
254
后端/README.md
Normal file
254
后端/README.md
Normal file
@@ -0,0 +1,254 @@
|
||||
# 优艺家沙发翻新小程序后端
|
||||
|
||||
基于 Node.js + NestJS + TypeORM 构建的沙发翻新服务小程序后端系统。
|
||||
|
||||
## 技术栈
|
||||
|
||||
- **框架**: NestJS v11.x
|
||||
- **数据库**: MySQL
|
||||
- **ORM**: TypeORM v0.3.x
|
||||
- **认证**: JWT (JSON Web Token)
|
||||
- **验证**: class-validator + class-transformer
|
||||
- **文档**: Swagger/OpenAPI
|
||||
- **开发语言**: TypeScript
|
||||
|
||||
## 功能模块
|
||||
|
||||
### 🔐 认证模块 (Auth)
|
||||
- 用户注册/登录
|
||||
- JWT Token 认证
|
||||
- 角色权限控制 (admin/customer/worker)
|
||||
- 刷新 Token 机制
|
||||
|
||||
### 👤 用户管理 (User)
|
||||
- 用户信息管理
|
||||
- 个人资料修改
|
||||
- 用户状态管理
|
||||
- 管理员用户操作
|
||||
|
||||
### 📸 案例管理 (Case)
|
||||
- 案例展示 (翻新前后对比)
|
||||
- 按服务类型筛选
|
||||
- 案例点赞功能
|
||||
- 我的案例管理
|
||||
|
||||
### 🔧 服务管理 (Service)
|
||||
- 服务项目配置
|
||||
- 价格管理
|
||||
- 服务状态控制
|
||||
- 排序管理
|
||||
|
||||
### 📅 预约管理 (Booking)
|
||||
- 在线预约服务
|
||||
- 预约状态跟踪
|
||||
- 工人分配
|
||||
- 价格报价
|
||||
|
||||
## 项目结构
|
||||
|
||||
```
|
||||
src/
|
||||
├── auth/ # 认证模块
|
||||
│ ├── dto/ # 数据传输对象
|
||||
│ ├── guards/ # 路由守卫
|
||||
│ ├── strategies/ # 认证策略
|
||||
│ └── decorators/ # 装饰器
|
||||
├── user/ # 用户管理
|
||||
├── case/ # 案例管理
|
||||
├── service/ # 服务管理
|
||||
├── booking/ # 预约管理 (待开发)
|
||||
├── entities/ # 数据库实体
|
||||
├── config/ # 配置文件
|
||||
└── main.ts # 应用入口
|
||||
```
|
||||
|
||||
## 环境要求
|
||||
|
||||
- Node.js >= 18.x
|
||||
- MySQL >= 8.0
|
||||
- npm >= 9.x
|
||||
|
||||
## 快速开始
|
||||
|
||||
### 1. 安装依赖
|
||||
|
||||
```bash
|
||||
npm install
|
||||
```
|
||||
|
||||
### 2. 环境配置
|
||||
|
||||
复制环境配置文件并填写配置信息:
|
||||
|
||||
```bash
|
||||
cp .env.example .env
|
||||
```
|
||||
|
||||
配置 `.env` 文件中的数据库连接信息:
|
||||
|
||||
```env
|
||||
# 数据库配置
|
||||
DB_HOST=localhost
|
||||
DB_PORT=3306
|
||||
DB_USER=root
|
||||
DB_PASS=your_password
|
||||
DB_NAME=sofa_renovation
|
||||
|
||||
# JWT 配置
|
||||
JWT_SECRET=your-super-secret-key
|
||||
JWT_EXPIRES_IN=7d
|
||||
JWT_REFRESH_SECRET=your-refresh-secret
|
||||
JWT_REFRESH_EXPIRES_IN=30d
|
||||
|
||||
# 服务器配置
|
||||
PORT=3000
|
||||
NODE_ENV=development
|
||||
|
||||
# 文件上传配置
|
||||
UPLOAD_DIR=uploads
|
||||
MAX_FILE_SIZE=5242880
|
||||
FRONTEND_URL=http://localhost:3000
|
||||
```
|
||||
|
||||
### 3. 数据库准备
|
||||
|
||||
确保 MySQL 服务运行,并创建数据库:
|
||||
|
||||
```sql
|
||||
CREATE DATABASE sofa_renovation CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||
```
|
||||
|
||||
### 4. 启动应用
|
||||
|
||||
```bash
|
||||
# 开发模式
|
||||
npm run start:dev
|
||||
|
||||
# 生产模式
|
||||
npm run start:prod
|
||||
```
|
||||
|
||||
应用启动后访问:
|
||||
- 应用地址: http://localhost:3000
|
||||
- API 文档: http://localhost:3000/docs
|
||||
|
||||
## API 文档
|
||||
|
||||
项目集成了 Swagger,启动服务后可以通过以下地址访问 API 文档:
|
||||
|
||||
```
|
||||
http://localhost:3000/docs
|
||||
```
|
||||
|
||||
### 主要 API 端点
|
||||
|
||||
#### 认证相关
|
||||
- `POST /api/auth/register` - 用户注册
|
||||
- `POST /api/auth/login` - 用户登录
|
||||
- `POST /api/auth/refresh` - 刷新令牌
|
||||
|
||||
#### 用户管理
|
||||
- `GET /api/users/profile` - 获取个人信息
|
||||
- `PATCH /api/users/profile` - 更新个人信息
|
||||
- `GET /api/users` - 获取用户列表 (管理员)
|
||||
|
||||
#### 案例管理
|
||||
- `GET /api/cases` - 获取案例列表
|
||||
- `GET /api/cases/:id` - 获取案例详情
|
||||
- `POST /api/cases` - 创建案例 (工人/管理员)
|
||||
- `POST /api/cases/:id/like` - 点赞案例
|
||||
|
||||
#### 服务管理
|
||||
- `GET /api/services` - 获取服务列表
|
||||
- `GET /api/services/active` - 获取有效服务
|
||||
- `POST /api/services` - 创建服务 (管理员)
|
||||
|
||||
## 数据库设计
|
||||
|
||||
### 用户表 (users)
|
||||
- 支持多角色:admin(管理员)、customer(客户)、worker(工人)
|
||||
- 用户状态管理:active、inactive、banned
|
||||
|
||||
### 案例表 (cases)
|
||||
- 翻新前后图片对比
|
||||
- 按服务类型分类
|
||||
- 浏览量和点赞统计
|
||||
|
||||
### 服务表 (services)
|
||||
- 服务类型:面料翻新、皮革翻新、清洁、维修、定制
|
||||
- 基础价格和预估工期
|
||||
- 排序和状态管理
|
||||
|
||||
### 预约表 (bookings)
|
||||
- 预约流程状态跟踪
|
||||
- 客户信息和服务需求
|
||||
- 工人分配和价格管理
|
||||
|
||||
## 开发指南
|
||||
|
||||
### 添加新的 API 接口
|
||||
|
||||
1. 在对应模块的 `dto` 文件夹中定义数据传输对象
|
||||
2. 在 `service` 中实现业务逻辑
|
||||
3. 在 `controller` 中定义路由和接口
|
||||
4. 添加适当的权限控制和验证
|
||||
|
||||
### 权限控制
|
||||
|
||||
使用装饰器进行权限控制:
|
||||
|
||||
```typescript
|
||||
// 公开接口 (无需认证)
|
||||
@Public()
|
||||
|
||||
// 需要特定角色
|
||||
@Roles('admin', 'worker')
|
||||
|
||||
// 获取当前用户信息
|
||||
@CurrentUser() user: CurrentUserData
|
||||
```
|
||||
|
||||
## 部署说明
|
||||
|
||||
### 生产环境配置
|
||||
|
||||
1. 设置 `NODE_ENV=production`
|
||||
2. 配置安全的 JWT 密钥
|
||||
3. 关闭数据库同步 `synchronize: false`
|
||||
4. 使用数据库迁移管理数据结构变更
|
||||
|
||||
### Docker 部署 (可选)
|
||||
|
||||
```dockerfile
|
||||
FROM node:18-alpine
|
||||
WORKDIR /app
|
||||
COPY package*.json ./
|
||||
RUN npm install
|
||||
COPY . .
|
||||
RUN npm run build
|
||||
EXPOSE 3000
|
||||
CMD ["npm", "run", "start:prod"]
|
||||
```
|
||||
|
||||
## 贡献指南
|
||||
|
||||
1. Fork 项目
|
||||
2. 创建特性分支 (`git checkout -b feature/AmazingFeature`)
|
||||
3. 提交更改 (`git commit -m 'Add some AmazingFeature'`)
|
||||
4. 推送到分支 (`git push origin feature/AmazingFeature`)
|
||||
5. 打开 Pull Request
|
||||
|
||||
## 许可证
|
||||
|
||||
本项目采用 MIT 许可证 - 查看 [LICENSE](LICENSE) 文件了解详情。
|
||||
|
||||
## 联系方式
|
||||
|
||||
如有问题或建议,请通过以下方式联系:
|
||||
|
||||
- 项目 Issues: [GitHub Issues](https://github.com/your-username/sofa-renovation-backend/issues)
|
||||
- 邮箱: your-email@example.com
|
||||
|
||||
---
|
||||
|
||||
*优艺家沙发翻新 - 让老沙发焕然一新* ✨
|
||||
278
后端/WECHAT_INTEGRATION.md
Normal file
278
后端/WECHAT_INTEGRATION.md
Normal file
@@ -0,0 +1,278 @@
|
||||
# 微信小程序接入指南
|
||||
|
||||
## 后端API接口
|
||||
|
||||
### 1. 微信小程序登录
|
||||
|
||||
**接口地址**: `POST /api/auth/wechat/login`
|
||||
|
||||
**请求体**:
|
||||
```json
|
||||
{
|
||||
"wechatLogin": {
|
||||
"code": "微信小程序wx.login()获取的code"
|
||||
},
|
||||
"userInfo": {
|
||||
"nickName": "用户昵称",
|
||||
"avatarUrl": "用户头像URL"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**响应**:
|
||||
```json
|
||||
{
|
||||
"user": {
|
||||
"id": 1,
|
||||
"username": "wx_12345678",
|
||||
"email": "openid@wechat.local",
|
||||
"realName": "用户昵称",
|
||||
"avatar": "用户头像URL",
|
||||
"role": "customer",
|
||||
"status": "active"
|
||||
},
|
||||
"access_token": "JWT访问令牌",
|
||||
"refresh_token": "JWT刷新令牌"
|
||||
}
|
||||
```
|
||||
|
||||
## 前端集成示例(uni-app)
|
||||
|
||||
### 1. 登录页面代码示例
|
||||
|
||||
```vue
|
||||
<template>
|
||||
<view class="login-container">
|
||||
<button @click="wxLogin" class="wx-login-btn">微信登录</button>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
methods: {
|
||||
// 微信登录
|
||||
async wxLogin() {
|
||||
try {
|
||||
// 1. 获取微信登录code
|
||||
const loginRes = await uni.login({
|
||||
provider: 'weixin'
|
||||
});
|
||||
|
||||
if (loginRes[1].code) {
|
||||
// 2. 获取用户信息(可选)
|
||||
const userInfo = await this.getUserInfo();
|
||||
|
||||
// 3. 发送到后端登录
|
||||
const response = await uni.request({
|
||||
url: 'http://localhost:3000/api/auth/wechat/login',
|
||||
method: 'POST',
|
||||
data: {
|
||||
wechatLogin: {
|
||||
code: loginRes[1].code
|
||||
},
|
||||
userInfo: userInfo
|
||||
}
|
||||
});
|
||||
|
||||
if (response[1].statusCode === 200) {
|
||||
const { user, access_token, refresh_token } = response[1].data;
|
||||
|
||||
// 4. 保存用户信息和token
|
||||
uni.setStorageSync('access_token', access_token);
|
||||
uni.setStorageSync('refresh_token', refresh_token);
|
||||
uni.setStorageSync('userInfo', user);
|
||||
|
||||
// 5. 跳转到首页
|
||||
uni.switchTab({
|
||||
url: '/pages/index/index'
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('登录失败:', error);
|
||||
uni.showToast({
|
||||
title: '登录失败',
|
||||
icon: 'none'
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
// 获取用户信息(可选)
|
||||
async getUserInfo() {
|
||||
try {
|
||||
const userProfile = await uni.getUserProfile({
|
||||
desc: '用于完善用户资料'
|
||||
});
|
||||
|
||||
return {
|
||||
nickName: userProfile[1].userInfo.nickName,
|
||||
avatarUrl: userProfile[1].userInfo.avatarUrl
|
||||
};
|
||||
} catch (error) {
|
||||
console.log('用户拒绝授权用户信息');
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style>
|
||||
.login-container {
|
||||
padding: 40px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.wx-login-btn {
|
||||
background-color: #07c160;
|
||||
color: white;
|
||||
border: none;
|
||||
padding: 12px 24px;
|
||||
border-radius: 4px;
|
||||
font-size: 16px;
|
||||
}
|
||||
</style>
|
||||
```
|
||||
|
||||
### 2. API请求拦截器
|
||||
|
||||
```javascript
|
||||
// utils/request.js
|
||||
const BASE_URL = 'http://localhost:3000/api';
|
||||
|
||||
// 请求拦截器
|
||||
uni.addInterceptor('request', {
|
||||
invoke(args) {
|
||||
// 添加基础URL
|
||||
if (!args.url.startsWith('http')) {
|
||||
args.url = BASE_URL + args.url;
|
||||
}
|
||||
|
||||
// 添加认证头
|
||||
const token = uni.getStorageSync('access_token');
|
||||
if (token) {
|
||||
args.header = {
|
||||
...args.header,
|
||||
'Authorization': `Bearer ${token}`
|
||||
};
|
||||
}
|
||||
|
||||
return args;
|
||||
},
|
||||
|
||||
success(result) {
|
||||
// 处理认证失败
|
||||
if (result.statusCode === 401) {
|
||||
// Token过期,尝试刷新或跳转登录
|
||||
uni.removeStorageSync('access_token');
|
||||
uni.removeStorageSync('userInfo');
|
||||
uni.reLaunch({
|
||||
url: '/pages/login/login'
|
||||
});
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
});
|
||||
|
||||
export default {
|
||||
// 封装请求方法
|
||||
request(options) {
|
||||
return uni.request({
|
||||
...options,
|
||||
header: {
|
||||
'Content-Type': 'application/json',
|
||||
...options.header
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 3. 用户信息获取
|
||||
|
||||
```javascript
|
||||
// pages/profile/profile.vue
|
||||
export default {
|
||||
data() {
|
||||
return {
|
||||
userInfo: {}
|
||||
};
|
||||
},
|
||||
|
||||
onLoad() {
|
||||
this.getUserProfile();
|
||||
},
|
||||
|
||||
methods: {
|
||||
async getUserProfile() {
|
||||
try {
|
||||
const response = await uni.request({
|
||||
url: '/users/profile',
|
||||
method: 'GET'
|
||||
});
|
||||
|
||||
if (response[1].statusCode === 200) {
|
||||
this.userInfo = response[1].data;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('获取用户信息失败:', error);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 配置说明
|
||||
|
||||
### 1. 后端环境配置
|
||||
|
||||
在 `.env` 文件中配置微信小程序信息:
|
||||
|
||||
```env
|
||||
# 微信小程序配置
|
||||
WECHAT_APPID=你的小程序AppID
|
||||
WECHAT_SECRET=你的小程序AppSecret
|
||||
```
|
||||
|
||||
### 2. 微信小程序配置
|
||||
|
||||
在 `manifest.json` 中配置微信小程序:
|
||||
|
||||
```json
|
||||
{
|
||||
"mp-weixin": {
|
||||
"appid": "你的小程序AppID",
|
||||
"setting": {
|
||||
"urlCheck": false
|
||||
},
|
||||
"requiredPrivateInfos": ["getUserProfile"]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 3. 请求域名配置
|
||||
|
||||
在微信小程序后台配置服务器域名:
|
||||
- 开发环境: `http://localhost:3000`
|
||||
- 生产环境: `https://yourdomain.com`
|
||||
|
||||
## 注意事项
|
||||
|
||||
1. **获取微信AppID和AppSecret**:
|
||||
- 登录微信小程序后台
|
||||
- 在"开发" -> "开发管理" -> "开发设置"中获取
|
||||
|
||||
2. **用户信息授权**:
|
||||
- `getUserProfile` 需要用户主动触发
|
||||
- 建议在用户首次登录时请求
|
||||
|
||||
3. **Token管理**:
|
||||
- 访问令牌有效期7天
|
||||
- 刷新令牌有效期30天
|
||||
- 建议在请求拦截器中处理token刷新
|
||||
|
||||
4. **安全考虑**:
|
||||
- AppSecret不要泄露到前端
|
||||
- 生产环境使用HTTPS
|
||||
- 定期更换JWT密钥
|
||||
35
后端/eslint.config.mjs
Normal file
35
后端/eslint.config.mjs
Normal file
@@ -0,0 +1,35 @@
|
||||
// @ts-check
|
||||
import eslint from '@eslint/js';
|
||||
import eslintPluginPrettierRecommended from 'eslint-plugin-prettier/recommended';
|
||||
import globals from 'globals';
|
||||
import tseslint from 'typescript-eslint';
|
||||
|
||||
export default tseslint.config(
|
||||
{
|
||||
ignores: ['eslint.config.mjs'],
|
||||
},
|
||||
eslint.configs.recommended,
|
||||
...tseslint.configs.recommendedTypeChecked,
|
||||
eslintPluginPrettierRecommended,
|
||||
{
|
||||
languageOptions: {
|
||||
globals: {
|
||||
...globals.node,
|
||||
...globals.jest,
|
||||
},
|
||||
sourceType: 'commonjs',
|
||||
parserOptions: {
|
||||
projectService: true,
|
||||
tsconfigRootDir: import.meta.dirname,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
rules: {
|
||||
'@typescript-eslint/no-explicit-any': 'off',
|
||||
'@typescript-eslint/no-floating-promises': 'warn',
|
||||
'@typescript-eslint/no-unsafe-argument': 'warn',
|
||||
"prettier/prettier": ["error", { endOfLine: "auto" }],
|
||||
},
|
||||
},
|
||||
);
|
||||
8
后端/nest-cli.json
Normal file
8
后端/nest-cli.json
Normal file
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/nest-cli",
|
||||
"collection": "@nestjs/schematics",
|
||||
"sourceRoot": "src",
|
||||
"compilerOptions": {
|
||||
"deleteOutDir": true
|
||||
}
|
||||
}
|
||||
10570
后端/package-lock.json
generated
Normal file
10570
后端/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
94
后端/package.json
Normal file
94
后端/package.json
Normal file
@@ -0,0 +1,94 @@
|
||||
{
|
||||
"name": "youyijia-sofa-backend",
|
||||
"version": "1.0.0",
|
||||
"description": "优艺家沙发翻新小程序后端 API",
|
||||
"author": "Your Name",
|
||||
"private": true,
|
||||
"license": "MIT",
|
||||
"scripts": {
|
||||
"build": "nest build",
|
||||
"format": "prettier --write \"src/**/*.ts\" \"test/**/*.ts\"",
|
||||
"start": "nest start",
|
||||
"start:dev": "nest start --watch",
|
||||
"start:debug": "nest start --debug --watch",
|
||||
"start:prod": "node dist/main",
|
||||
"lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix",
|
||||
"test": "jest",
|
||||
"test:watch": "jest --watch",
|
||||
"test:cov": "jest --coverage",
|
||||
"test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand",
|
||||
"test:e2e": "jest --config ./test/jest-e2e.json",
|
||||
"typeorm": "typeorm-ts-node-commonjs"
|
||||
},
|
||||
"dependencies": {
|
||||
"@nestjs/common": "^11.0.1",
|
||||
"@nestjs/config": "^4.0.0",
|
||||
"@nestjs/core": "^11.0.1",
|
||||
"@nestjs/jwt": "^11.0.0",
|
||||
"@nestjs/passport": "^11.0.0",
|
||||
"@nestjs/platform-express": "^11.0.1",
|
||||
"@nestjs/swagger": "^8.0.0",
|
||||
"@nestjs/typeorm": "^11.0.0",
|
||||
"axios": "^1.13.3",
|
||||
"bcrypt": "^5.1.1",
|
||||
"class-transformer": "^0.5.1",
|
||||
"class-validator": "^0.14.0",
|
||||
"dayjs": "^1.11.9",
|
||||
"multer": "^1.4.5-lts.1",
|
||||
"mysql2": "^3.6.0",
|
||||
"passport": "^0.6.0",
|
||||
"passport-jwt": "^4.0.1",
|
||||
"passport-local": "^1.0.0",
|
||||
"reflect-metadata": "^0.2.2",
|
||||
"rxjs": "^7.8.1",
|
||||
"typeorm": "^0.3.17",
|
||||
"uuid": "^9.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/eslintrc": "^3.2.0",
|
||||
"@eslint/js": "^9.18.0",
|
||||
"@nestjs/cli": "^11.0.0",
|
||||
"@nestjs/schematics": "^11.0.0",
|
||||
"@nestjs/testing": "^11.0.1",
|
||||
"@types/bcrypt": "^5.0.0",
|
||||
"@types/express": "^5.0.0",
|
||||
"@types/jest": "^30.0.0",
|
||||
"@types/multer": "^1.4.7",
|
||||
"@types/node": "^22.10.7",
|
||||
"@types/passport-jwt": "^3.0.9",
|
||||
"@types/passport-local": "^1.0.35",
|
||||
"@types/supertest": "^6.0.2",
|
||||
"@types/uuid": "^9.0.2",
|
||||
"eslint": "^9.18.0",
|
||||
"eslint-config-prettier": "^10.0.1",
|
||||
"eslint-plugin-prettier": "^5.2.2",
|
||||
"globals": "^16.0.0",
|
||||
"jest": "^30.0.0",
|
||||
"prettier": "^3.4.2",
|
||||
"source-map-support": "^0.5.21",
|
||||
"supertest": "^7.0.0",
|
||||
"ts-jest": "^29.2.5",
|
||||
"ts-loader": "^9.5.2",
|
||||
"ts-node": "^10.9.2",
|
||||
"tsconfig-paths": "^4.2.0",
|
||||
"typescript": "^5.7.3",
|
||||
"typescript-eslint": "^8.20.0"
|
||||
},
|
||||
"jest": {
|
||||
"moduleFileExtensions": [
|
||||
"js",
|
||||
"json",
|
||||
"ts"
|
||||
],
|
||||
"rootDir": "src",
|
||||
"testRegex": ".*\\.spec\\.ts$",
|
||||
"transform": {
|
||||
"^.+\\.(t|j)s$": "ts-jest"
|
||||
},
|
||||
"collectCoverageFrom": [
|
||||
"**/*.(t|j)s"
|
||||
],
|
||||
"coverageDirectory": "../coverage",
|
||||
"testEnvironment": "node"
|
||||
}
|
||||
}
|
||||
22
后端/src/app.controller.spec.ts
Normal file
22
后端/src/app.controller.spec.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
import { AppController } from './app.controller';
|
||||
import { AppService } from './app.service';
|
||||
|
||||
describe('AppController', () => {
|
||||
let appController: AppController;
|
||||
|
||||
beforeEach(async () => {
|
||||
const app: TestingModule = await Test.createTestingModule({
|
||||
controllers: [AppController],
|
||||
providers: [AppService],
|
||||
}).compile();
|
||||
|
||||
appController = app.get<AppController>(AppController);
|
||||
});
|
||||
|
||||
describe('root', () => {
|
||||
it('should return "Hello World!"', () => {
|
||||
expect(appController.getHello()).toBe('Hello World!');
|
||||
});
|
||||
});
|
||||
});
|
||||
12
后端/src/app.controller.ts
Normal file
12
后端/src/app.controller.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import { Controller, Get } from '@nestjs/common';
|
||||
import { AppService } from './app.service';
|
||||
|
||||
@Controller()
|
||||
export class AppController {
|
||||
constructor(private readonly appService: AppService) {}
|
||||
|
||||
@Get()
|
||||
getHello(): string {
|
||||
return this.appService.getHello();
|
||||
}
|
||||
}
|
||||
52
后端/src/app.module.ts
Normal file
52
后端/src/app.module.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { ConfigModule, ConfigService } from '@nestjs/config';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { AppController } from './app.controller';
|
||||
import { AppService } from './app.service';
|
||||
import databaseConfig from './config/database.config';
|
||||
import jwtConfig from './config/jwt.config';
|
||||
import { User } from './entities/user.entity';
|
||||
import { Case } from './entities/case.entity';
|
||||
import { Service } from './entities/service.entity';
|
||||
import { Booking } from './entities/booking.entity';
|
||||
import { AuthModule } from './auth/auth.module';
|
||||
import { UserModule } from './user/user.module';
|
||||
import { CaseModule } from './case/case.module';
|
||||
import { ServiceModule } from './service/service.module';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
ConfigModule.forRoot({
|
||||
isGlobal: true,
|
||||
load: [databaseConfig, jwtConfig],
|
||||
envFilePath: '.env',
|
||||
}),
|
||||
TypeOrmModule.forRootAsync({
|
||||
imports: [ConfigModule],
|
||||
useFactory: (configService: ConfigService) => ({
|
||||
type: 'mysql',
|
||||
host: configService.get('database.host'),
|
||||
port: configService.get('database.port'),
|
||||
username: configService.get('database.username'),
|
||||
password: configService.get('database.password'),
|
||||
database: configService.get('database.database'),
|
||||
entities: [User, Case, Service, Booking],
|
||||
synchronize: configService.get('database.synchronize'),
|
||||
logging: configService.get('database.logging'),
|
||||
charset: configService.get('database.charset'),
|
||||
timezone: configService.get('database.timezone'),
|
||||
retryAttempts: 3, // 减少重试次数
|
||||
retryDelay: 1000, // 重试延迟
|
||||
autoLoadEntities: true,
|
||||
}),
|
||||
inject: [ConfigService],
|
||||
}),
|
||||
AuthModule,
|
||||
UserModule,
|
||||
CaseModule,
|
||||
ServiceModule,
|
||||
],
|
||||
controllers: [AppController],
|
||||
providers: [AppService],
|
||||
})
|
||||
export class AppModule {}
|
||||
8
后端/src/app.service.ts
Normal file
8
后端/src/app.service.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
|
||||
@Injectable()
|
||||
export class AppService {
|
||||
getHello(): string {
|
||||
return 'Hello World!';
|
||||
}
|
||||
}
|
||||
53
后端/src/auth/auth.controller.ts
Normal file
53
后端/src/auth/auth.controller.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
import { Controller, Post, Body, HttpStatus, HttpCode } from '@nestjs/common';
|
||||
import { ApiTags, ApiOperation, ApiResponse } from '@nestjs/swagger';
|
||||
import { AuthService, AuthResult } from './auth.service';
|
||||
import { RegisterDto, LoginDto, WechatLoginDto, WechatUserInfoDto } from './dto/auth.dto';
|
||||
import { Public } from './guards/public.decorator';
|
||||
|
||||
@ApiTags('认证')
|
||||
@Controller('auth')
|
||||
export class AuthController {
|
||||
constructor(private readonly authService: AuthService) {}
|
||||
|
||||
@Public()
|
||||
@Post('register')
|
||||
@ApiOperation({ summary: '用户注册' })
|
||||
@ApiResponse({ status: 201, description: '注册成功' })
|
||||
@ApiResponse({ status: 409, description: '用户已存在' })
|
||||
async register(@Body() registerDto: RegisterDto): Promise<AuthResult> {
|
||||
return this.authService.register(registerDto);
|
||||
}
|
||||
|
||||
@Public()
|
||||
@Post('login')
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@ApiOperation({ summary: '用户登录' })
|
||||
@ApiResponse({ status: 200, description: '登录成功' })
|
||||
@ApiResponse({ status: 401, description: '用户名或密码错误' })
|
||||
async login(@Body() loginDto: LoginDto): Promise<AuthResult> {
|
||||
return this.authService.login(loginDto);
|
||||
}
|
||||
|
||||
@Public()
|
||||
@Post('refresh')
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@ApiOperation({ summary: '刷新访问令牌' })
|
||||
@ApiResponse({ status: 200, description: '刷新成功' })
|
||||
@ApiResponse({ status: 401, description: '刷新令牌无效' })
|
||||
async refreshToken(@Body('refresh_token') refreshToken: string) {
|
||||
return this.authService.refreshToken(refreshToken);
|
||||
}
|
||||
|
||||
@Public()
|
||||
@Post('wechat/login')
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@ApiOperation({ summary: '微信小程序登录' })
|
||||
@ApiResponse({ status: 200, description: '登录成功' })
|
||||
@ApiResponse({ status: 400, description: '微信登录失败' })
|
||||
async wechatLogin(
|
||||
@Body() loginData: { wechatLogin: WechatLoginDto; userInfo?: WechatUserInfoDto }
|
||||
): Promise<AuthResult> {
|
||||
const { wechatLogin, userInfo } = loginData;
|
||||
return this.authService.wechatLogin(wechatLogin, userInfo);
|
||||
}
|
||||
}
|
||||
43
后端/src/auth/auth.module.ts
Normal file
43
后端/src/auth/auth.module.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { JwtModule } from '@nestjs/jwt';
|
||||
import { ConfigModule, ConfigService } from '@nestjs/config';
|
||||
import { PassportModule } from '@nestjs/passport';
|
||||
import { APP_GUARD } from '@nestjs/core';
|
||||
import { AuthController } from './auth.controller';
|
||||
import { AuthService } from './auth.service';
|
||||
import { WechatService } from './wechat.service';
|
||||
import { JwtStrategy } from './strategies/jwt.strategy';
|
||||
import { JwtAuthGuard } from './guards/jwt-auth.guard';
|
||||
import { RolesGuard } from './guards/roles.guard';
|
||||
import { UserModule } from '../user/user.module';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
UserModule,
|
||||
PassportModule,
|
||||
JwtModule.registerAsync({
|
||||
imports: [ConfigModule],
|
||||
useFactory: async (configService: ConfigService) => ({
|
||||
secret: configService.get<string>('jwt.secret'),
|
||||
signOptions: configService.get('jwt.signOptions'),
|
||||
}),
|
||||
inject: [ConfigService],
|
||||
}),
|
||||
],
|
||||
controllers: [AuthController],
|
||||
providers: [
|
||||
AuthService,
|
||||
WechatService,
|
||||
JwtStrategy,
|
||||
{
|
||||
provide: APP_GUARD,
|
||||
useClass: JwtAuthGuard,
|
||||
},
|
||||
{
|
||||
provide: APP_GUARD,
|
||||
useClass: RolesGuard,
|
||||
},
|
||||
],
|
||||
exports: [AuthService],
|
||||
})
|
||||
export class AuthModule {}
|
||||
248
后端/src/auth/auth.service.ts
Normal file
248
后端/src/auth/auth.service.ts
Normal file
@@ -0,0 +1,248 @@
|
||||
import { Injectable, ConflictException, UnauthorizedException, BadRequestException } from '@nestjs/common';
|
||||
import { JwtService } from '@nestjs/jwt';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import * as bcrypt from 'bcrypt';
|
||||
import { UserService } from '../user/user.service';
|
||||
import { RegisterDto, LoginDto, WechatLoginDto, WechatUserInfoDto } from './dto/auth.dto';
|
||||
import { JwtPayload } from './strategies/jwt.strategy';
|
||||
import { WechatService } from './wechat.service';
|
||||
|
||||
export interface AuthResult {
|
||||
user: {
|
||||
id: number;
|
||||
username: string;
|
||||
email: string;
|
||||
realName: string;
|
||||
phone: string;
|
||||
avatar: string;
|
||||
role: string;
|
||||
status: string;
|
||||
};
|
||||
access_token: string;
|
||||
refresh_token: string;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class AuthService {
|
||||
constructor(
|
||||
private userService: UserService,
|
||||
private jwtService: JwtService,
|
||||
private configService: ConfigService,
|
||||
private wechatService: WechatService,
|
||||
) {}
|
||||
|
||||
async register(registerDto: RegisterDto): Promise<AuthResult> {
|
||||
const { username, email, password, realName, phone } = registerDto;
|
||||
|
||||
// 检查用户名是否已存在
|
||||
const existingUsername = await this.userService.findByUsername(username);
|
||||
if (existingUsername) {
|
||||
throw new ConflictException('用户名已存在');
|
||||
}
|
||||
|
||||
// 检查邮箱是否已存在
|
||||
const existingEmail = await this.userService.findByEmail(email);
|
||||
if (existingEmail) {
|
||||
throw new ConflictException('邮箱已存在');
|
||||
}
|
||||
|
||||
// 加密密码
|
||||
const saltRounds = 10;
|
||||
const hashedPassword = await bcrypt.hash(password, saltRounds);
|
||||
|
||||
// 创建用户
|
||||
const user = await this.userService.create({
|
||||
username,
|
||||
email,
|
||||
password: hashedPassword,
|
||||
realName,
|
||||
phone,
|
||||
role: 'customer',
|
||||
status: 'active',
|
||||
});
|
||||
|
||||
// 生成tokens
|
||||
const tokens = await this.generateTokens(user);
|
||||
|
||||
return {
|
||||
user: {
|
||||
id: user.id,
|
||||
username: user.username,
|
||||
email: user.email,
|
||||
realName: user.realName,
|
||||
phone: user.phone,
|
||||
avatar: user.avatar,
|
||||
role: user.role,
|
||||
status: user.status,
|
||||
},
|
||||
...tokens,
|
||||
};
|
||||
}
|
||||
|
||||
async login(loginDto: LoginDto): Promise<AuthResult> {
|
||||
const { username, password } = loginDto;
|
||||
|
||||
// 查找用户(支持用户名或邮箱登录)
|
||||
let user;
|
||||
if (username.includes('@')) {
|
||||
user = await this.userService.findByEmailWithPassword(username);
|
||||
} else {
|
||||
user = await this.userService.findByUsernameWithPassword(username);
|
||||
}
|
||||
|
||||
if (!user) {
|
||||
throw new UnauthorizedException('用户名或密码错误');
|
||||
}
|
||||
|
||||
// 检查用户状态
|
||||
if (user.status !== 'active') {
|
||||
throw new UnauthorizedException('账户已被禁用,请联系管理员');
|
||||
}
|
||||
|
||||
// 验证密码
|
||||
const isPasswordValid = await bcrypt.compare(password, user.password);
|
||||
if (!isPasswordValid) {
|
||||
throw new UnauthorizedException('用户名或密码错误');
|
||||
}
|
||||
|
||||
// 生成tokens
|
||||
const tokens = await this.generateTokens(user);
|
||||
|
||||
return {
|
||||
user: {
|
||||
id: user.id,
|
||||
username: user.username,
|
||||
email: user.email,
|
||||
realName: user.realName,
|
||||
phone: user.phone,
|
||||
avatar: user.avatar,
|
||||
role: user.role,
|
||||
status: user.status,
|
||||
},
|
||||
...tokens,
|
||||
};
|
||||
}
|
||||
|
||||
async refreshToken(refreshToken: string): Promise<{ access_token: string }> {
|
||||
try {
|
||||
const payload = this.jwtService.verify(refreshToken, {
|
||||
secret: this.configService.get('jwt.refreshSecret'),
|
||||
});
|
||||
|
||||
const user = await this.userService.findById(payload.sub);
|
||||
if (!user || user.status !== 'active') {
|
||||
throw new UnauthorizedException('用户不存在或已被禁用');
|
||||
}
|
||||
|
||||
const jwtPayload: JwtPayload = {
|
||||
sub: user.id,
|
||||
username: user.username,
|
||||
email: user.email,
|
||||
role: user.role,
|
||||
};
|
||||
|
||||
return {
|
||||
access_token: this.jwtService.sign(jwtPayload),
|
||||
};
|
||||
} catch (error) {
|
||||
throw new UnauthorizedException('刷新令牌无效');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 微信小程序登录
|
||||
*/
|
||||
async wechatLogin(wechatLoginDto: WechatLoginDto, userInfo?: WechatUserInfoDto): Promise<AuthResult> {
|
||||
const { code, encryptedData, iv, signature } = wechatLoginDto;
|
||||
|
||||
// 通过code获取微信session信息
|
||||
const wechatSession = await this.wechatService.getWechatSession(code);
|
||||
const { openid, session_key, unionid } = wechatSession;
|
||||
|
||||
// 查找是否已存在该微信用户
|
||||
let user = await this.userService.findByOpenid(openid);
|
||||
|
||||
if (user) {
|
||||
// 更新session_key
|
||||
await this.userService.updateSessionKey(user.id, session_key);
|
||||
|
||||
// 生成tokens
|
||||
const tokens = await this.generateTokens(user);
|
||||
|
||||
return {
|
||||
user: {
|
||||
id: user.id,
|
||||
username: user.username,
|
||||
email: user.email,
|
||||
realName: user.realName,
|
||||
phone: user.phone,
|
||||
avatar: user.avatar,
|
||||
role: user.role,
|
||||
status: user.status,
|
||||
},
|
||||
...tokens,
|
||||
};
|
||||
}
|
||||
|
||||
// 新用户注册流程
|
||||
let decryptedUserInfo: any = null;
|
||||
|
||||
// 如果提供了加密数据,解密获取用户信息
|
||||
if (encryptedData && iv) {
|
||||
decryptedUserInfo = this.wechatService.decryptWechatData(encryptedData, iv, session_key);
|
||||
}
|
||||
|
||||
// 生成唯一用户名
|
||||
const username = this.wechatService.generateUniqueUsername(openid);
|
||||
|
||||
// 创建新用户
|
||||
user = await this.userService.create({
|
||||
username,
|
||||
email: `${openid}@wechat.local`, // 临时邮箱
|
||||
realName: decryptedUserInfo?.nickName || userInfo?.nickName || '微信用户',
|
||||
avatar: decryptedUserInfo?.avatarUrl || userInfo?.avatarUrl || null,
|
||||
role: 'customer',
|
||||
status: 'active',
|
||||
openid,
|
||||
unionid,
|
||||
sessionKey: session_key,
|
||||
});
|
||||
|
||||
// 生成tokens
|
||||
const tokens = await this.generateTokens(user);
|
||||
|
||||
return {
|
||||
user: {
|
||||
id: user.id,
|
||||
username: user.username,
|
||||
email: user.email,
|
||||
realName: user.realName,
|
||||
phone: user.phone,
|
||||
avatar: user.avatar,
|
||||
role: user.role,
|
||||
status: user.status,
|
||||
},
|
||||
...tokens,
|
||||
};
|
||||
}
|
||||
|
||||
private async generateTokens(user: any) {
|
||||
const payload: JwtPayload = {
|
||||
sub: user.id,
|
||||
username: user.username,
|
||||
email: user.email,
|
||||
role: user.role,
|
||||
};
|
||||
|
||||
const accessToken = this.jwtService.sign(payload);
|
||||
const refreshToken = this.jwtService.sign(payload, {
|
||||
secret: this.configService.get('jwt.refreshSecret'),
|
||||
expiresIn: this.configService.get('jwt.refreshExpiresIn'),
|
||||
});
|
||||
|
||||
return {
|
||||
access_token: accessToken,
|
||||
refresh_token: refreshToken,
|
||||
};
|
||||
}
|
||||
}
|
||||
15
后端/src/auth/decorators/current-user.decorator.ts
Normal file
15
后端/src/auth/decorators/current-user.decorator.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import { createParamDecorator, ExecutionContext } from '@nestjs/common';
|
||||
|
||||
export interface CurrentUserData {
|
||||
userId: number;
|
||||
username: string;
|
||||
email: string;
|
||||
role: string;
|
||||
}
|
||||
|
||||
export const CurrentUser = createParamDecorator(
|
||||
(data: unknown, ctx: ExecutionContext): CurrentUserData => {
|
||||
const request = ctx.switchToHttp().getRequest();
|
||||
return request.user;
|
||||
},
|
||||
);
|
||||
75
后端/src/auth/dto/auth.dto.ts
Normal file
75
后端/src/auth/dto/auth.dto.ts
Normal file
@@ -0,0 +1,75 @@
|
||||
import { IsEmail, IsNotEmpty, IsString, MinLength, IsOptional } from 'class-validator';
|
||||
|
||||
export class RegisterDto {
|
||||
@IsNotEmpty({ message: '用户名不能为空' })
|
||||
@IsString({ message: '用户名必须为字符串' })
|
||||
username: string;
|
||||
|
||||
@IsEmail({}, { message: '邮箱格式不正确' })
|
||||
email: string;
|
||||
|
||||
@IsNotEmpty({ message: '密码不能为空' })
|
||||
@MinLength(6, { message: '密码长度至少6位' })
|
||||
password: string;
|
||||
|
||||
@IsOptional()
|
||||
realName?: string;
|
||||
|
||||
@IsOptional()
|
||||
phone?: string;
|
||||
}
|
||||
|
||||
export class LoginDto {
|
||||
@IsNotEmpty({ message: '登录名不能为空' })
|
||||
@IsString()
|
||||
username: string; // 可以是用户名或邮箱
|
||||
|
||||
@IsNotEmpty({ message: '密码不能为空' })
|
||||
password: string;
|
||||
}
|
||||
|
||||
// 微信小程序登录DTO
|
||||
export class WechatLoginDto {
|
||||
@IsNotEmpty({ message: '微信登录码不能为空' })
|
||||
@IsString()
|
||||
code: string; // 微信小程序登录码
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
encryptedData?: string; // 加密数据
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
iv?: string; // 初始向量
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
signature?: string; // 数据签名
|
||||
}
|
||||
|
||||
// 微信用户信息DTO
|
||||
export class WechatUserInfoDto {
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
nickName?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
avatarUrl?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
gender?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
city?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
province?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
country?: string;
|
||||
}
|
||||
29
后端/src/auth/guards/jwt-auth.guard.ts
Normal file
29
后端/src/auth/guards/jwt-auth.guard.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import { Injectable, CanActivate, ExecutionContext, UnauthorizedException } from '@nestjs/common';
|
||||
import { Reflector } from '@nestjs/core';
|
||||
import { AuthGuard } from '@nestjs/passport';
|
||||
import { IS_PUBLIC_KEY } from './public.decorator';
|
||||
|
||||
@Injectable()
|
||||
export class JwtAuthGuard extends AuthGuard('jwt') {
|
||||
constructor(private reflector: Reflector) {
|
||||
super();
|
||||
}
|
||||
|
||||
canActivate(context: ExecutionContext) {
|
||||
const isPublic = this.reflector.getAllAndOverride<boolean>(IS_PUBLIC_KEY, [
|
||||
context.getHandler(),
|
||||
context.getClass(),
|
||||
]);
|
||||
if (isPublic) {
|
||||
return true;
|
||||
}
|
||||
return super.canActivate(context);
|
||||
}
|
||||
|
||||
handleRequest(err, user, info) {
|
||||
if (err || !user) {
|
||||
throw err || new UnauthorizedException('登录已过期,请重新登录');
|
||||
}
|
||||
return user;
|
||||
}
|
||||
}
|
||||
4
后端/src/auth/guards/public.decorator.ts
Normal file
4
后端/src/auth/guards/public.decorator.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
import { SetMetadata } from '@nestjs/common';
|
||||
|
||||
export const IS_PUBLIC_KEY = 'isPublic';
|
||||
export const Public = () => SetMetadata(IS_PUBLIC_KEY, true);
|
||||
4
后端/src/auth/guards/roles.decorator.ts
Normal file
4
后端/src/auth/guards/roles.decorator.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
import { SetMetadata } from '@nestjs/common';
|
||||
|
||||
export const ROLES_KEY = 'roles';
|
||||
export const Roles = (...roles: string[]) => SetMetadata(ROLES_KEY, roles);
|
||||
27
后端/src/auth/guards/roles.guard.ts
Normal file
27
后端/src/auth/guards/roles.guard.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import { Injectable, CanActivate, ExecutionContext, ForbiddenException } from '@nestjs/common';
|
||||
import { Reflector } from '@nestjs/core';
|
||||
import { ROLES_KEY } from './roles.decorator';
|
||||
|
||||
@Injectable()
|
||||
export class RolesGuard implements CanActivate {
|
||||
constructor(private reflector: Reflector) {}
|
||||
|
||||
canActivate(context: ExecutionContext): boolean {
|
||||
const requiredRoles = this.reflector.getAllAndOverride<string[]>(ROLES_KEY, [
|
||||
context.getHandler(),
|
||||
context.getClass(),
|
||||
]);
|
||||
if (!requiredRoles) {
|
||||
return true;
|
||||
}
|
||||
const { user } = context.switchToHttp().getRequest();
|
||||
if (!user) {
|
||||
throw new ForbiddenException('用户未认证');
|
||||
}
|
||||
const hasRole = requiredRoles.includes(user.role);
|
||||
if (!hasRole) {
|
||||
throw new ForbiddenException('权限不足');
|
||||
}
|
||||
return true;
|
||||
}
|
||||
}
|
||||
39
后端/src/auth/strategies/jwt.strategy.ts
Normal file
39
后端/src/auth/strategies/jwt.strategy.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { PassportStrategy } from '@nestjs/passport';
|
||||
import { ExtractJwt, Strategy } from 'passport-jwt';
|
||||
import { UserService } from '../../user/user.service';
|
||||
|
||||
export interface JwtPayload {
|
||||
sub: number;
|
||||
username: string;
|
||||
email: string;
|
||||
role: string;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class JwtStrategy extends PassportStrategy(Strategy) {
|
||||
constructor(
|
||||
private configService: ConfigService,
|
||||
private userService: UserService,
|
||||
) {
|
||||
super({
|
||||
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
|
||||
ignoreExpiration: false,
|
||||
secretOrKey: configService.get('jwt.secret'),
|
||||
});
|
||||
}
|
||||
|
||||
async validate(payload: JwtPayload) {
|
||||
const user = await this.userService.findById(payload.sub);
|
||||
if (!user || user.status !== 'active') {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
userId: payload.sub,
|
||||
username: payload.username,
|
||||
email: payload.email,
|
||||
role: payload.role,
|
||||
};
|
||||
}
|
||||
}
|
||||
125
后端/src/auth/wechat.service.ts
Normal file
125
后端/src/auth/wechat.service.ts
Normal file
@@ -0,0 +1,125 @@
|
||||
import { Injectable, Logger, BadRequestException } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import axios from 'axios';
|
||||
import * as crypto from 'crypto';
|
||||
|
||||
export interface WechatSession {
|
||||
openid: string;
|
||||
session_key: string;
|
||||
unionid?: string;
|
||||
errcode?: number;
|
||||
errmsg?: string;
|
||||
}
|
||||
|
||||
export interface WechatUserInfo {
|
||||
openId: string;
|
||||
nickName: string;
|
||||
gender: number;
|
||||
city: string;
|
||||
province: string;
|
||||
country: string;
|
||||
avatarUrl: string;
|
||||
unionId?: string;
|
||||
watermark: {
|
||||
timestamp: number;
|
||||
appid: string;
|
||||
};
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class WechatService {
|
||||
private readonly logger = new Logger(WechatService.name);
|
||||
private readonly appId: string;
|
||||
private readonly appSecret: string;
|
||||
|
||||
constructor(private configService: ConfigService) {
|
||||
this.appId = this.configService.get<string>('WECHAT_APPID') || '';
|
||||
this.appSecret = this.configService.get<string>('WECHAT_SECRET') || '';
|
||||
}
|
||||
|
||||
/**
|
||||
* 通过code换取微信用户的openid和session_key
|
||||
*/
|
||||
async getWechatSession(code: string): Promise<WechatSession> {
|
||||
try {
|
||||
const response = await axios.get('https://api.weixin.qq.com/sns/jscode2session', {
|
||||
params: {
|
||||
appid: this.appId,
|
||||
secret: this.appSecret,
|
||||
js_code: code,
|
||||
grant_type: 'authorization_code',
|
||||
},
|
||||
});
|
||||
|
||||
const data = response.data;
|
||||
|
||||
if (data.errcode) {
|
||||
this.logger.error(`微信登录失败: ${data.errcode} - ${data.errmsg}`);
|
||||
throw new BadRequestException(`微信登录失败: ${data.errmsg}`);
|
||||
}
|
||||
|
||||
return {
|
||||
openid: data.openid,
|
||||
session_key: data.session_key,
|
||||
unionid: data.unionid,
|
||||
};
|
||||
} catch (error) {
|
||||
this.logger.error('调用微信API失败:', error);
|
||||
throw new BadRequestException('微信登录服务暂时不可用');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 解密微信用户信息
|
||||
*/
|
||||
decryptWechatData(encryptedData: string, iv: string, sessionKey: string): WechatUserInfo {
|
||||
try {
|
||||
const key = Buffer.from(sessionKey, 'base64');
|
||||
const ivBuffer = Buffer.from(iv, 'base64');
|
||||
const encrypted = Buffer.from(encryptedData, 'base64');
|
||||
|
||||
const decipher = crypto.createDecipheriv('aes-128-cbc', key, ivBuffer);
|
||||
decipher.setAutoPadding(true);
|
||||
|
||||
let decrypted = decipher.update(encrypted, undefined, 'utf8');
|
||||
decrypted += decipher.final('utf8');
|
||||
|
||||
const userInfo = JSON.parse(decrypted);
|
||||
|
||||
// 验证水印
|
||||
if (userInfo.watermark.appid !== this.appId) {
|
||||
throw new Error('水印验证失败');
|
||||
}
|
||||
|
||||
return userInfo;
|
||||
} catch (error) {
|
||||
this.logger.error('解密微信用户信息失败:', error);
|
||||
throw new BadRequestException('用户信息解密失败');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证数据签名
|
||||
*/
|
||||
verifySignature(rawData: string, signature: string, sessionKey: string): boolean {
|
||||
try {
|
||||
const hmac = crypto.createHmac('sha1', sessionKey);
|
||||
hmac.update(rawData);
|
||||
const computedSignature = hmac.digest('hex');
|
||||
|
||||
return computedSignature === signature;
|
||||
} catch (error) {
|
||||
this.logger.error('验证签名失败:', error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成唯一用户名
|
||||
*/
|
||||
generateUniqueUsername(openid: string): string {
|
||||
// 使用openid的一部分作为用户名后缀,确保唯一性
|
||||
const suffix = openid.slice(-8);
|
||||
return `wx_${suffix}`;
|
||||
}
|
||||
}
|
||||
89
后端/src/case/case.controller.ts
Normal file
89
后端/src/case/case.controller.ts
Normal file
@@ -0,0 +1,89 @@
|
||||
import { Controller, Get, Post, Body, Patch, Param, Delete, Query, UseGuards } from '@nestjs/common';
|
||||
import { ApiTags, ApiOperation, ApiResponse, ApiBearerAuth, ApiQuery } from '@nestjs/swagger';
|
||||
import { CaseService } from './case.service';
|
||||
import { CreateCaseDto, UpdateCaseDto, QueryCaseDto } from './dto/case.dto';
|
||||
import type { CurrentUserData } from '../auth/decorators/current-user.decorator';
|
||||
import { CurrentUser } from '../auth/decorators/current-user.decorator';
|
||||
import { Roles } from '../auth/guards/roles.decorator';
|
||||
import { Public } from '../auth/guards/public.decorator';
|
||||
|
||||
@ApiTags('案例管理')
|
||||
@Controller('cases')
|
||||
export class CaseController {
|
||||
constructor(private readonly caseService: CaseService) {}
|
||||
|
||||
@ApiBearerAuth()
|
||||
@Post()
|
||||
@Roles('admin', 'worker')
|
||||
@ApiOperation({ summary: '创建案例' })
|
||||
@ApiResponse({ status: 201, description: '创建成功' })
|
||||
create(@Body() createCaseDto: CreateCaseDto, @CurrentUser() user: CurrentUserData) {
|
||||
return this.caseService.create(createCaseDto, user.userId);
|
||||
}
|
||||
|
||||
@Public()
|
||||
@Get()
|
||||
@ApiOperation({ summary: '获取案例列表' })
|
||||
@ApiQuery({ name: 'serviceType', required: false, enum: ['fabric', 'leather', 'cleaning', 'repair', 'custom'] })
|
||||
@ApiQuery({ name: 'status', required: false, enum: ['draft', 'published', 'archived'], description: '默认为published' })
|
||||
@ApiQuery({ name: 'page', required: false, type: Number, description: '页码,默认为1' })
|
||||
@ApiQuery({ name: 'limit', required: false, type: Number, description: '每页数量,默认为10' })
|
||||
@ApiResponse({ status: 200, description: '获取成功' })
|
||||
findAll(@Query() query: QueryCaseDto) {
|
||||
return this.caseService.findAll(query);
|
||||
}
|
||||
|
||||
@ApiBearerAuth()
|
||||
@Get('my')
|
||||
@ApiOperation({ summary: '获取我的案例列表' })
|
||||
@ApiQuery({ name: 'serviceType', required: false, enum: ['fabric', 'leather', 'cleaning', 'repair', 'custom'] })
|
||||
@ApiQuery({ name: 'status', required: false, enum: ['draft', 'published', 'archived'] })
|
||||
@ApiQuery({ name: 'page', required: false, type: Number })
|
||||
@ApiQuery({ name: 'limit', required: false, type: Number })
|
||||
@ApiResponse({ status: 200, description: '获取成功' })
|
||||
getMyCases(@Query() query: QueryCaseDto, @CurrentUser() user: CurrentUserData) {
|
||||
return this.caseService.getMyCases(user.userId, query);
|
||||
}
|
||||
|
||||
@Public()
|
||||
@Get(':id')
|
||||
@ApiOperation({ summary: '根据ID获取案例详情' })
|
||||
@ApiResponse({ status: 200, description: '获取成功' })
|
||||
@ApiResponse({ status: 404, description: '案例不存在' })
|
||||
findOne(@Param('id') id: string) {
|
||||
return this.caseService.findOne(+id);
|
||||
}
|
||||
|
||||
@ApiBearerAuth()
|
||||
@Patch(':id')
|
||||
@ApiOperation({ summary: '更新案例' })
|
||||
@ApiResponse({ status: 200, description: '更新成功' })
|
||||
@ApiResponse({ status: 404, description: '案例不存在' })
|
||||
@ApiResponse({ status: 403, description: '没有权限' })
|
||||
update(
|
||||
@Param('id') id: string,
|
||||
@Body() updateCaseDto: UpdateCaseDto,
|
||||
@CurrentUser() user: CurrentUserData
|
||||
) {
|
||||
return this.caseService.update(+id, updateCaseDto, user.userId, user.role);
|
||||
}
|
||||
|
||||
@ApiBearerAuth()
|
||||
@Delete(':id')
|
||||
@ApiOperation({ summary: '删除案例' })
|
||||
@ApiResponse({ status: 200, description: '删除成功' })
|
||||
@ApiResponse({ status: 404, description: '案例不存在' })
|
||||
@ApiResponse({ status: 403, description: '没有权限' })
|
||||
remove(@Param('id') id: string, @CurrentUser() user: CurrentUserData) {
|
||||
return this.caseService.remove(+id, user.userId, user.role);
|
||||
}
|
||||
|
||||
@Public()
|
||||
@Post(':id/like')
|
||||
@ApiOperation({ summary: '点赞案例' })
|
||||
@ApiResponse({ status: 200, description: '点赞成功' })
|
||||
@ApiResponse({ status: 404, description: '案例不存在' })
|
||||
like(@Param('id') id: string) {
|
||||
return this.caseService.like(+id);
|
||||
}
|
||||
}
|
||||
13
后端/src/case/case.module.ts
Normal file
13
后端/src/case/case.module.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { CaseService } from './case.service';
|
||||
import { CaseController } from './case.controller';
|
||||
import { Case } from '../entities/case.entity';
|
||||
|
||||
@Module({
|
||||
imports: [TypeOrmModule.forFeature([Case])],
|
||||
controllers: [CaseController],
|
||||
providers: [CaseService],
|
||||
exports: [CaseService],
|
||||
})
|
||||
export class CaseModule {}
|
||||
207
后端/src/case/case.service.ts
Normal file
207
后端/src/case/case.service.ts
Normal file
@@ -0,0 +1,207 @@
|
||||
import { Injectable, NotFoundException, ForbiddenException } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
import { Case } from '../entities/case.entity';
|
||||
import { CreateCaseDto, UpdateCaseDto, QueryCaseDto } from './dto/case.dto';
|
||||
|
||||
@Injectable()
|
||||
export class CaseService {
|
||||
constructor(
|
||||
@InjectRepository(Case)
|
||||
private caseRepository: Repository<Case>,
|
||||
) {}
|
||||
|
||||
async create(createCaseDto: CreateCaseDto, createdBy: number): Promise<Case> {
|
||||
const caseEntity = this.caseRepository.create({
|
||||
...createCaseDto,
|
||||
createdBy,
|
||||
status: 'published',
|
||||
});
|
||||
return this.caseRepository.save(caseEntity);
|
||||
}
|
||||
|
||||
async findAll(query: QueryCaseDto) {
|
||||
const { serviceType, status, page = 1, limit = 10 } = query;
|
||||
const queryBuilder = this.caseRepository
|
||||
.createQueryBuilder('case')
|
||||
.leftJoinAndSelect('case.creator', 'creator')
|
||||
.select([
|
||||
'case.id',
|
||||
'case.title',
|
||||
'case.description',
|
||||
'case.beforeImages',
|
||||
'case.afterImages',
|
||||
'case.serviceType',
|
||||
'case.price',
|
||||
'case.materials',
|
||||
'case.duration',
|
||||
'case.status',
|
||||
'case.views',
|
||||
'case.likes',
|
||||
'case.createdAt',
|
||||
'case.updatedAt',
|
||||
'creator.id',
|
||||
'creator.username',
|
||||
'creator.realName',
|
||||
]);
|
||||
|
||||
if (serviceType) {
|
||||
queryBuilder.andWhere('case.serviceType = :serviceType', { serviceType });
|
||||
}
|
||||
|
||||
if (status) {
|
||||
queryBuilder.andWhere('case.status = :status', { status });
|
||||
}
|
||||
|
||||
const skip = (page - 1) * limit;
|
||||
queryBuilder.skip(skip).take(limit);
|
||||
queryBuilder.orderBy('case.createdAt', 'DESC');
|
||||
|
||||
const [items, total] = await queryBuilder.getManyAndCount();
|
||||
|
||||
return {
|
||||
items,
|
||||
total,
|
||||
page,
|
||||
limit,
|
||||
totalPages: Math.ceil(total / limit),
|
||||
};
|
||||
}
|
||||
|
||||
async findOne(id: number): Promise<Case> {
|
||||
const caseEntity = await this.caseRepository
|
||||
.createQueryBuilder('case')
|
||||
.leftJoinAndSelect('case.creator', 'creator')
|
||||
.select([
|
||||
'case.id',
|
||||
'case.title',
|
||||
'case.description',
|
||||
'case.beforeImages',
|
||||
'case.afterImages',
|
||||
'case.serviceType',
|
||||
'case.price',
|
||||
'case.materials',
|
||||
'case.duration',
|
||||
'case.status',
|
||||
'case.views',
|
||||
'case.likes',
|
||||
'case.createdAt',
|
||||
'case.updatedAt',
|
||||
'creator.id',
|
||||
'creator.username',
|
||||
'creator.realName',
|
||||
])
|
||||
.where('case.id = :id', { id })
|
||||
.getOne();
|
||||
|
||||
if (!caseEntity) {
|
||||
throw new NotFoundException('案例不存在');
|
||||
}
|
||||
|
||||
// 增加浏览量
|
||||
await this.caseRepository.update(id, { views: () => 'views + 1' });
|
||||
|
||||
return caseEntity;
|
||||
}
|
||||
|
||||
async update(id: number, updateCaseDto: UpdateCaseDto, userId: number, userRole: string): Promise<Case> {
|
||||
const caseEntity = await this.caseRepository.findOne({
|
||||
where: { id },
|
||||
relations: ['creator'],
|
||||
});
|
||||
|
||||
if (!caseEntity) {
|
||||
throw new NotFoundException('案例不存在');
|
||||
}
|
||||
|
||||
// 检查权限:只有创建者或管理员可以修改
|
||||
if (caseEntity.createdBy !== userId && userRole !== 'admin') {
|
||||
throw new ForbiddenException('没有权限修改此案例');
|
||||
}
|
||||
|
||||
await this.caseRepository.update(id, updateCaseDto);
|
||||
return this.findOne(id);
|
||||
}
|
||||
|
||||
async remove(id: number, userId: number, userRole: string): Promise<void> {
|
||||
const caseEntity = await this.caseRepository.findOne({
|
||||
where: { id },
|
||||
});
|
||||
|
||||
if (!caseEntity) {
|
||||
throw new NotFoundException('案例不存在');
|
||||
}
|
||||
|
||||
// 检查权限:只有创建者或管理员可以删除
|
||||
if (caseEntity.createdBy !== userId && userRole !== 'admin') {
|
||||
throw new ForbiddenException('没有权限删除此案例');
|
||||
}
|
||||
|
||||
await this.caseRepository.remove(caseEntity);
|
||||
}
|
||||
|
||||
async like(id: number): Promise<{ likes: number }> {
|
||||
const caseEntity = await this.caseRepository.findOne({ where: { id } });
|
||||
if (!caseEntity) {
|
||||
throw new NotFoundException('案例不存在');
|
||||
}
|
||||
|
||||
await this.caseRepository.update(id, { likes: () => 'likes + 1' });
|
||||
const updatedCase = await this.caseRepository.findOne({ where: { id } });
|
||||
|
||||
if (!updatedCase) {
|
||||
throw new NotFoundException('更新后案例不存在');
|
||||
}
|
||||
|
||||
return { likes: updatedCase.likes };
|
||||
}
|
||||
|
||||
async getMyCases(userId: number, query: QueryCaseDto) {
|
||||
const { serviceType, status, page = 1, limit = 10 } = query;
|
||||
const queryBuilder = this.caseRepository
|
||||
.createQueryBuilder('case')
|
||||
.leftJoinAndSelect('case.creator', 'creator')
|
||||
.where('case.createdBy = :userId', { userId })
|
||||
.select([
|
||||
'case.id',
|
||||
'case.title',
|
||||
'case.description',
|
||||
'case.beforeImages',
|
||||
'case.afterImages',
|
||||
'case.serviceType',
|
||||
'case.price',
|
||||
'case.materials',
|
||||
'case.duration',
|
||||
'case.status',
|
||||
'case.views',
|
||||
'case.likes',
|
||||
'case.createdAt',
|
||||
'case.updatedAt',
|
||||
'creator.id',
|
||||
'creator.username',
|
||||
'creator.realName',
|
||||
]);
|
||||
|
||||
if (serviceType) {
|
||||
queryBuilder.andWhere('case.serviceType = :serviceType', { serviceType });
|
||||
}
|
||||
|
||||
if (status) {
|
||||
queryBuilder.andWhere('case.status = :status', { status });
|
||||
}
|
||||
|
||||
const skip = (page - 1) * limit;
|
||||
queryBuilder.skip(skip).take(limit);
|
||||
queryBuilder.orderBy('case.createdAt', 'DESC');
|
||||
|
||||
const [items, total] = await queryBuilder.getManyAndCount();
|
||||
|
||||
return {
|
||||
items,
|
||||
total,
|
||||
page,
|
||||
limit,
|
||||
totalPages: Math.ceil(total / limit),
|
||||
};
|
||||
}
|
||||
}
|
||||
103
后端/src/case/dto/case.dto.ts
Normal file
103
后端/src/case/dto/case.dto.ts
Normal file
@@ -0,0 +1,103 @@
|
||||
import { IsNotEmpty, IsString, IsOptional, IsEnum, IsNumber, IsArray, Min } from 'class-validator';
|
||||
import { Type } from 'class-transformer';
|
||||
|
||||
export class CreateCaseDto {
|
||||
@IsNotEmpty({ message: '标题不能为空' })
|
||||
@IsString()
|
||||
title: string;
|
||||
|
||||
@IsNotEmpty({ message: '描述不能为空' })
|
||||
@IsString()
|
||||
description: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
beforeImages?: string[];
|
||||
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
afterImages?: string[];
|
||||
|
||||
@IsEnum(['fabric', 'leather', 'cleaning', 'repair', 'custom'])
|
||||
serviceType: string;
|
||||
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@IsNumber()
|
||||
@Min(0)
|
||||
price?: number;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
materials?: string;
|
||||
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@IsNumber()
|
||||
@Min(0)
|
||||
duration?: number;
|
||||
}
|
||||
|
||||
export class UpdateCaseDto {
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
title?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
description?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
beforeImages?: string[];
|
||||
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
afterImages?: string[];
|
||||
|
||||
@IsOptional()
|
||||
@IsEnum(['fabric', 'leather', 'cleaning', 'repair', 'custom'])
|
||||
serviceType?: string;
|
||||
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@IsNumber()
|
||||
@Min(0)
|
||||
price?: number;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
materials?: string;
|
||||
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@IsNumber()
|
||||
@Min(0)
|
||||
duration?: number;
|
||||
|
||||
@IsOptional()
|
||||
@IsEnum(['draft', 'published', 'archived'])
|
||||
status?: string;
|
||||
}
|
||||
|
||||
export class QueryCaseDto {
|
||||
@IsOptional()
|
||||
@IsEnum(['fabric', 'leather', 'cleaning', 'repair', 'custom'])
|
||||
serviceType?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsEnum(['draft', 'published', 'archived'])
|
||||
status?: string = 'published';
|
||||
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@IsNumber()
|
||||
@Min(1)
|
||||
page?: number = 1;
|
||||
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@IsNumber()
|
||||
@Min(1)
|
||||
limit?: number = 10;
|
||||
}
|
||||
15
后端/src/config/database.config.ts
Normal file
15
后端/src/config/database.config.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import { registerAs } from '@nestjs/config';
|
||||
|
||||
export default registerAs('database', () => ({
|
||||
type: 'mysql',
|
||||
host: process.env.DB_HOST || 'localhost',
|
||||
port: parseInt(process.env.DB_PORT || '3306', 10),
|
||||
username: process.env.DB_USER || 'root',
|
||||
password: process.env.DB_PASS || '',
|
||||
database: process.env.DB_NAME || 'sofa_renovation',
|
||||
autoLoadEntities: true,
|
||||
synchronize: process.env.NODE_ENV !== 'production', // 生产环境请设置为false,使用迁移
|
||||
logging: process.env.NODE_ENV === 'development',
|
||||
charset: 'utf8mb4',
|
||||
timezone: '+08:00',
|
||||
}));
|
||||
10
后端/src/config/jwt.config.ts
Normal file
10
后端/src/config/jwt.config.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import { registerAs } from '@nestjs/config';
|
||||
|
||||
export default registerAs('jwt', () => ({
|
||||
secret: process.env.JWT_SECRET || 'your-secret-key',
|
||||
signOptions: {
|
||||
expiresIn: process.env.JWT_EXPIRES_IN || '7d',
|
||||
},
|
||||
refreshSecret: process.env.JWT_REFRESH_SECRET || 'your-refresh-secret',
|
||||
refreshExpiresIn: process.env.JWT_REFRESH_EXPIRES_IN || '30d',
|
||||
}));
|
||||
73
后端/src/entities/booking.entity.ts
Normal file
73
后端/src/entities/booking.entity.ts
Normal file
@@ -0,0 +1,73 @@
|
||||
import { Entity, PrimaryGeneratedColumn, Column, CreateDateColumn, UpdateDateColumn, ManyToOne, JoinColumn } from 'typeorm';
|
||||
import { User } from './user.entity';
|
||||
import { Service } from './service.entity';
|
||||
|
||||
@Entity('bookings')
|
||||
export class Booking {
|
||||
@PrimaryGeneratedColumn()
|
||||
id: number;
|
||||
|
||||
@Column({ length: 32, unique: true })
|
||||
bookingNumber: string; // 预约编号
|
||||
|
||||
@ManyToOne(() => User)
|
||||
@JoinColumn({ name: 'customerId' })
|
||||
customer: User;
|
||||
|
||||
@Column()
|
||||
customerId: number;
|
||||
|
||||
@ManyToOne(() => Service)
|
||||
@JoinColumn({ name: 'serviceId' })
|
||||
service: Service;
|
||||
|
||||
@Column()
|
||||
serviceId: number;
|
||||
|
||||
@Column({ length: 50 })
|
||||
contactName: string;
|
||||
|
||||
@Column({ length: 20 })
|
||||
contactPhone: string;
|
||||
|
||||
@Column({ type: 'text' })
|
||||
address: string;
|
||||
|
||||
@Column({ type: 'datetime' })
|
||||
appointmentTime: Date;
|
||||
|
||||
@Column({ type: 'text', nullable: true })
|
||||
requirements: string; // 特殊要求
|
||||
|
||||
@Column({ type: 'json', nullable: true })
|
||||
images: string[]; // 沙发现状图片
|
||||
|
||||
@Column({
|
||||
type: 'enum',
|
||||
enum: ['pending', 'confirmed', 'in_progress', 'completed', 'cancelled'],
|
||||
default: 'pending'
|
||||
})
|
||||
status: string;
|
||||
|
||||
@Column({ type: 'decimal', precision: 10, scale: 2, nullable: true })
|
||||
quotedPrice: number; // 报价
|
||||
|
||||
@Column({ type: 'decimal', precision: 10, scale: 2, nullable: true })
|
||||
finalPrice: number; // 最终价格
|
||||
|
||||
@Column({ type: 'text', nullable: true })
|
||||
notes: string; // 备注
|
||||
|
||||
@ManyToOne(() => User, { nullable: true })
|
||||
@JoinColumn({ name: 'assignedWorkerId' })
|
||||
assignedWorker: User;
|
||||
|
||||
@Column({ nullable: true })
|
||||
assignedWorkerId: number;
|
||||
|
||||
@CreateDateColumn()
|
||||
createdAt: Date;
|
||||
|
||||
@UpdateDateColumn()
|
||||
updatedAt: Date;
|
||||
}
|
||||
62
后端/src/entities/case.entity.ts
Normal file
62
后端/src/entities/case.entity.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
import { Entity, PrimaryGeneratedColumn, Column, CreateDateColumn, UpdateDateColumn, ManyToOne, JoinColumn } from 'typeorm';
|
||||
import { User } from './user.entity';
|
||||
|
||||
@Entity('cases')
|
||||
export class Case {
|
||||
@PrimaryGeneratedColumn()
|
||||
id: number;
|
||||
|
||||
@Column({ length: 100 })
|
||||
title: string;
|
||||
|
||||
@Column({ type: 'text' })
|
||||
description: string;
|
||||
|
||||
@Column({ type: 'json', nullable: true })
|
||||
beforeImages: string[];
|
||||
|
||||
@Column({ type: 'json', nullable: true })
|
||||
afterImages: string[];
|
||||
|
||||
@Column({
|
||||
type: 'enum',
|
||||
enum: ['fabric', 'leather', 'cleaning', 'repair', 'custom'],
|
||||
default: 'fabric'
|
||||
})
|
||||
serviceType: string;
|
||||
|
||||
@Column({ type: 'decimal', precision: 10, scale: 2, nullable: true })
|
||||
price: number;
|
||||
|
||||
@Column({ type: 'text', nullable: true })
|
||||
materials: string;
|
||||
|
||||
@Column({ type: 'int', default: 0 })
|
||||
duration: number; // 工作天数
|
||||
|
||||
@Column({
|
||||
type: 'enum',
|
||||
enum: ['draft', 'published', 'archived'],
|
||||
default: 'published'
|
||||
})
|
||||
status: string;
|
||||
|
||||
@Column({ type: 'int', default: 0 })
|
||||
views: number;
|
||||
|
||||
@Column({ type: 'int', default: 0 })
|
||||
likes: number;
|
||||
|
||||
@ManyToOne(() => User)
|
||||
@JoinColumn({ name: 'createdBy' })
|
||||
creator: User;
|
||||
|
||||
@Column({ nullable: true })
|
||||
createdBy: number;
|
||||
|
||||
@CreateDateColumn()
|
||||
createdAt: Date;
|
||||
|
||||
@UpdateDateColumn()
|
||||
updatedAt: Date;
|
||||
}
|
||||
48
后端/src/entities/service.entity.ts
Normal file
48
后端/src/entities/service.entity.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
import { Entity, PrimaryGeneratedColumn, Column, CreateDateColumn, UpdateDateColumn } from 'typeorm';
|
||||
|
||||
@Entity('services')
|
||||
export class Service {
|
||||
@PrimaryGeneratedColumn()
|
||||
id: number;
|
||||
|
||||
@Column({ length: 100 })
|
||||
name: string;
|
||||
|
||||
@Column({ type: 'text' })
|
||||
description: string;
|
||||
|
||||
@Column({
|
||||
type: 'enum',
|
||||
enum: ['fabric', 'leather', 'cleaning', 'repair', 'custom'],
|
||||
unique: true
|
||||
})
|
||||
type: string;
|
||||
|
||||
@Column({ type: 'decimal', precision: 10, scale: 2 })
|
||||
basePrice: number;
|
||||
|
||||
@Column({ type: 'json', nullable: true })
|
||||
images: string[];
|
||||
|
||||
@Column({ type: 'json', nullable: true })
|
||||
features: string[]; // 服务特点
|
||||
|
||||
@Column({ type: 'int', default: 1 })
|
||||
estimatedDays: number; // 预估工期
|
||||
|
||||
@Column({
|
||||
type: 'enum',
|
||||
enum: ['active', 'inactive'],
|
||||
default: 'active'
|
||||
})
|
||||
status: string;
|
||||
|
||||
@Column({ type: 'int', default: 0 })
|
||||
sortOrder: number;
|
||||
|
||||
@CreateDateColumn()
|
||||
createdAt: Date;
|
||||
|
||||
@UpdateDateColumn()
|
||||
updatedAt: Date;
|
||||
}
|
||||
55
后端/src/entities/user.entity.ts
Normal file
55
后端/src/entities/user.entity.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
import { Entity, PrimaryGeneratedColumn, Column, CreateDateColumn, UpdateDateColumn } from 'typeorm';
|
||||
|
||||
@Entity('users')
|
||||
export class User {
|
||||
@PrimaryGeneratedColumn()
|
||||
id: number;
|
||||
|
||||
@Column({ unique: true, length: 50, nullable: true })
|
||||
username: string;
|
||||
|
||||
@Column({ unique: true, length: 100, nullable: true })
|
||||
email: string;
|
||||
|
||||
@Column({ select: false, nullable: true })
|
||||
password: string;
|
||||
|
||||
@Column({ length: 50, nullable: true })
|
||||
realName: string;
|
||||
|
||||
@Column({ length: 20, nullable: true })
|
||||
phone: string;
|
||||
|
||||
@Column({ type: 'text', nullable: true })
|
||||
avatar: string;
|
||||
|
||||
// 微信小程序相关字段
|
||||
@Column({ unique: true, length: 50, nullable: true })
|
||||
openid: string; // 微信openid
|
||||
|
||||
@Column({ length: 100, nullable: true })
|
||||
unionid: string; // 微信unionid
|
||||
|
||||
@Column({ type: 'text', nullable: true })
|
||||
sessionKey: string; // 微信session_key
|
||||
|
||||
@Column({
|
||||
type: 'enum',
|
||||
enum: ['admin', 'customer', 'worker'],
|
||||
default: 'customer'
|
||||
})
|
||||
role: string;
|
||||
|
||||
@Column({
|
||||
type: 'enum',
|
||||
enum: ['active', 'inactive', 'banned'],
|
||||
default: 'active'
|
||||
})
|
||||
status: string;
|
||||
|
||||
@CreateDateColumn()
|
||||
createdAt: Date;
|
||||
|
||||
@UpdateDateColumn()
|
||||
updatedAt: Date;
|
||||
}
|
||||
52
后端/src/main.ts
Normal file
52
后端/src/main.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
import { NestFactory } from '@nestjs/core';
|
||||
import { ValidationPipe } from '@nestjs/common';
|
||||
import { SwaggerModule, DocumentBuilder } from '@nestjs/swagger';
|
||||
import { AppModule } from './app.module';
|
||||
|
||||
async function bootstrap() {
|
||||
const app = await NestFactory.create(AppModule);
|
||||
|
||||
// 启用全局验证管道
|
||||
app.useGlobalPipes(new ValidationPipe({
|
||||
transform: true,
|
||||
whitelist: true,
|
||||
forbidNonWhitelisted: true,
|
||||
}));
|
||||
|
||||
// 启用 CORS
|
||||
app.enableCors({
|
||||
origin: process.env.FRONTEND_URL || true,
|
||||
methods: 'GET,HEAD,PUT,PATCH,POST,DELETE',
|
||||
credentials: true,
|
||||
});
|
||||
|
||||
// API 前缀
|
||||
app.setGlobalPrefix('api');
|
||||
|
||||
// Swagger 文档配置
|
||||
const config = new DocumentBuilder()
|
||||
.setTitle('优艺家沙发翻新 API')
|
||||
.setDescription('沙发翻新小程序后端API文档')
|
||||
.setVersion('1.0')
|
||||
.addBearerAuth()
|
||||
.addTag('认证', '用户认证相关接口')
|
||||
.addTag('用户管理', '用户管理相关接口')
|
||||
.addTag('案例管理', '案例展示相关接口')
|
||||
.addTag('服务管理', '服务项目相关接口')
|
||||
.addTag('预约管理', '预约订单相关接口')
|
||||
.build();
|
||||
|
||||
const document = SwaggerModule.createDocument(app, config);
|
||||
SwaggerModule.setup('docs', app, document, {
|
||||
swaggerOptions: {
|
||||
persistAuthorization: true,
|
||||
},
|
||||
});
|
||||
|
||||
const port = process.env.PORT || 3000;
|
||||
await app.listen(port);
|
||||
|
||||
console.log(`🚀 Application is running on: http://localhost:${port}`);
|
||||
console.log(`📚 API Documentation: http://localhost:${port}/docs`);
|
||||
}
|
||||
bootstrap();
|
||||
93
后端/src/service/dto/service.dto.ts
Normal file
93
后端/src/service/dto/service.dto.ts
Normal file
@@ -0,0 +1,93 @@
|
||||
import { IsNotEmpty, IsString, IsOptional, IsEnum, IsNumber, IsArray, Min } from 'class-validator';
|
||||
import { Type } from 'class-transformer';
|
||||
|
||||
export class CreateServiceDto {
|
||||
@IsNotEmpty({ message: '服务名称不能为空' })
|
||||
@IsString()
|
||||
name: string;
|
||||
|
||||
@IsNotEmpty({ message: '服务描述不能为空' })
|
||||
@IsString()
|
||||
description: string;
|
||||
|
||||
@IsEnum(['fabric', 'leather', 'cleaning', 'repair', 'custom'])
|
||||
type: string;
|
||||
|
||||
@IsNotEmpty({ message: '基础价格不能为空' })
|
||||
@Type(() => Number)
|
||||
@IsNumber()
|
||||
@Min(0)
|
||||
basePrice: number;
|
||||
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
images?: string[];
|
||||
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
features?: string[];
|
||||
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@IsNumber()
|
||||
@Min(1)
|
||||
estimatedDays?: number;
|
||||
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@IsNumber()
|
||||
sortOrder?: number;
|
||||
}
|
||||
|
||||
export class UpdateServiceDto {
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
name?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
description?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsEnum(['fabric', 'leather', 'cleaning', 'repair', 'custom'])
|
||||
type?: string;
|
||||
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@IsNumber()
|
||||
@Min(0)
|
||||
basePrice?: number;
|
||||
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
images?: string[];
|
||||
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
features?: string[];
|
||||
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@IsNumber()
|
||||
@Min(1)
|
||||
estimatedDays?: number;
|
||||
|
||||
@IsOptional()
|
||||
@IsEnum(['active', 'inactive'])
|
||||
status?: string;
|
||||
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@IsNumber()
|
||||
sortOrder?: number;
|
||||
}
|
||||
|
||||
export class QueryServiceDto {
|
||||
@IsOptional()
|
||||
@IsEnum(['fabric', 'leather', 'cleaning', 'repair', 'custom'])
|
||||
type?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsEnum(['active', 'inactive'])
|
||||
status?: string = 'active';
|
||||
}
|
||||
98
后端/src/service/service.controller.ts
Normal file
98
后端/src/service/service.controller.ts
Normal file
@@ -0,0 +1,98 @@
|
||||
import { Controller, Get, Post, Body, Patch, Param, Delete, Query, UseGuards } from '@nestjs/common';
|
||||
import { ApiTags, ApiOperation, ApiResponse, ApiBearerAuth, ApiQuery } from '@nestjs/swagger';
|
||||
import { ServiceService } from './service.service';
|
||||
import { CreateServiceDto, UpdateServiceDto, QueryServiceDto } from './dto/service.dto';
|
||||
import { Roles } from '../auth/guards/roles.decorator';
|
||||
import { Public } from '../auth/guards/public.decorator';
|
||||
|
||||
@ApiTags('服务管理')
|
||||
@Controller('services')
|
||||
export class ServiceController {
|
||||
constructor(private readonly serviceService: ServiceService) {}
|
||||
|
||||
@ApiBearerAuth()
|
||||
@Post()
|
||||
@Roles('admin')
|
||||
@ApiOperation({ summary: '创建服务(管理员)' })
|
||||
@ApiResponse({ status: 201, description: '创建成功' })
|
||||
@ApiResponse({ status: 409, description: '服务类型已存在' })
|
||||
create(@Body() createServiceDto: CreateServiceDto) {
|
||||
return this.serviceService.create(createServiceDto);
|
||||
}
|
||||
|
||||
@Public()
|
||||
@Get()
|
||||
@ApiOperation({ summary: '获取服务列表' })
|
||||
@ApiQuery({ name: 'type', required: false, enum: ['fabric', 'leather', 'cleaning', 'repair', 'custom'] })
|
||||
@ApiQuery({ name: 'status', required: false, enum: ['active', 'inactive'], description: '默认为active' })
|
||||
@ApiResponse({ status: 200, description: '获取成功' })
|
||||
findAll(@Query() query: QueryServiceDto) {
|
||||
return this.serviceService.findAll(query);
|
||||
}
|
||||
|
||||
@Public()
|
||||
@Get('active')
|
||||
@ApiOperation({ summary: '获取所有有效服务' })
|
||||
@ApiResponse({ status: 200, description: '获取成功' })
|
||||
getActiveServices() {
|
||||
return this.serviceService.getActiveServices();
|
||||
}
|
||||
|
||||
@Public()
|
||||
@Get(':id')
|
||||
@ApiOperation({ summary: '根据ID获取服务详情' })
|
||||
@ApiResponse({ status: 200, description: '获取成功' })
|
||||
@ApiResponse({ status: 404, description: '服务不存在' })
|
||||
findOne(@Param('id') id: string) {
|
||||
return this.serviceService.findOne(+id);
|
||||
}
|
||||
|
||||
@Public()
|
||||
@Get('type/:type')
|
||||
@ApiOperation({ summary: '根据类型获取服务' })
|
||||
@ApiResponse({ status: 200, description: '获取成功' })
|
||||
@ApiResponse({ status: 404, description: '服务类型不存在' })
|
||||
findByType(@Param('type') type: string) {
|
||||
return this.serviceService.findByType(type);
|
||||
}
|
||||
|
||||
@ApiBearerAuth()
|
||||
@Patch(':id')
|
||||
@Roles('admin')
|
||||
@ApiOperation({ summary: '更新服务(管理员)' })
|
||||
@ApiResponse({ status: 200, description: '更新成功' })
|
||||
@ApiResponse({ status: 404, description: '服务不存在' })
|
||||
@ApiResponse({ status: 409, description: '服务类型已存在' })
|
||||
update(@Param('id') id: string, @Body() updateServiceDto: UpdateServiceDto) {
|
||||
return this.serviceService.update(+id, updateServiceDto);
|
||||
}
|
||||
|
||||
@ApiBearerAuth()
|
||||
@Delete(':id')
|
||||
@Roles('admin')
|
||||
@ApiOperation({ summary: '删除服务(管理员)' })
|
||||
@ApiResponse({ status: 200, description: '删除成功' })
|
||||
@ApiResponse({ status: 404, description: '服务不存在' })
|
||||
remove(@Param('id') id: string) {
|
||||
return this.serviceService.remove(+id);
|
||||
}
|
||||
|
||||
@ApiBearerAuth()
|
||||
@Patch(':id/toggle-status')
|
||||
@Roles('admin')
|
||||
@ApiOperation({ summary: '切换服务状态(管理员)' })
|
||||
@ApiResponse({ status: 200, description: '状态切换成功' })
|
||||
@ApiResponse({ status: 404, description: '服务不存在' })
|
||||
toggleStatus(@Param('id') id: string) {
|
||||
return this.serviceService.toggleStatus(+id);
|
||||
}
|
||||
|
||||
@ApiBearerAuth()
|
||||
@Patch('sort-order')
|
||||
@Roles('admin')
|
||||
@ApiOperation({ summary: '更新服务排序(管理员)' })
|
||||
@ApiResponse({ status: 200, description: '排序更新成功' })
|
||||
updateSortOrder(@Body() serviceOrders: { id: number; sortOrder: number }[]) {
|
||||
return this.serviceService.updateSortOrder(serviceOrders);
|
||||
}
|
||||
}
|
||||
13
后端/src/service/service.module.ts
Normal file
13
后端/src/service/service.module.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { ServiceService } from './service.service';
|
||||
import { ServiceController } from './service.controller';
|
||||
import { Service } from '../entities/service.entity';
|
||||
|
||||
@Module({
|
||||
imports: [TypeOrmModule.forFeature([Service])],
|
||||
controllers: [ServiceController],
|
||||
providers: [ServiceService],
|
||||
exports: [ServiceService],
|
||||
})
|
||||
export class ServiceModule {}
|
||||
117
后端/src/service/service.service.ts
Normal file
117
后端/src/service/service.service.ts
Normal file
@@ -0,0 +1,117 @@
|
||||
import { Injectable, NotFoundException, ConflictException } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
import { Service } from '../entities/service.entity';
|
||||
import { CreateServiceDto, UpdateServiceDto, QueryServiceDto } from './dto/service.dto';
|
||||
|
||||
@Injectable()
|
||||
export class ServiceService {
|
||||
constructor(
|
||||
@InjectRepository(Service)
|
||||
private serviceRepository: Repository<Service>,
|
||||
) {}
|
||||
|
||||
async create(createServiceDto: CreateServiceDto): Promise<Service> {
|
||||
// 检查服务类型是否已存在
|
||||
const existingService = await this.serviceRepository.findOne({
|
||||
where: { type: createServiceDto.type }
|
||||
});
|
||||
|
||||
if (existingService) {
|
||||
throw new ConflictException('该服务类型已存在');
|
||||
}
|
||||
|
||||
const service = this.serviceRepository.create({
|
||||
...createServiceDto,
|
||||
status: 'active',
|
||||
});
|
||||
|
||||
return this.serviceRepository.save(service);
|
||||
}
|
||||
|
||||
async findAll(query: QueryServiceDto): Promise<Service[]> {
|
||||
const { type, status } = query;
|
||||
const queryBuilder = this.serviceRepository.createQueryBuilder('service');
|
||||
|
||||
if (type) {
|
||||
queryBuilder.andWhere('service.type = :type', { type });
|
||||
}
|
||||
|
||||
if (status) {
|
||||
queryBuilder.andWhere('service.status = :status', { status });
|
||||
}
|
||||
|
||||
queryBuilder.orderBy('service.sortOrder', 'ASC');
|
||||
queryBuilder.addOrderBy('service.createdAt', 'DESC');
|
||||
|
||||
return queryBuilder.getMany();
|
||||
}
|
||||
|
||||
async findOne(id: number): Promise<Service> {
|
||||
const service = await this.serviceRepository.findOne({
|
||||
where: { id }
|
||||
});
|
||||
|
||||
if (!service) {
|
||||
throw new NotFoundException('服务不存在');
|
||||
}
|
||||
|
||||
return service;
|
||||
}
|
||||
|
||||
async findByType(type: string): Promise<Service> {
|
||||
const service = await this.serviceRepository.findOne({
|
||||
where: { type }
|
||||
});
|
||||
|
||||
if (!service) {
|
||||
throw new NotFoundException('服务类型不存在');
|
||||
}
|
||||
|
||||
return service;
|
||||
}
|
||||
|
||||
async update(id: number, updateServiceDto: UpdateServiceDto): Promise<Service> {
|
||||
const service = await this.findOne(id);
|
||||
|
||||
// 如果要更新服务类型,检查新类型是否已被其他服务使用
|
||||
if (updateServiceDto.type && updateServiceDto.type !== service.type) {
|
||||
const existingService = await this.serviceRepository.findOne({
|
||||
where: { type: updateServiceDto.type }
|
||||
});
|
||||
|
||||
if (existingService) {
|
||||
throw new ConflictException('该服务类型已存在');
|
||||
}
|
||||
}
|
||||
|
||||
await this.serviceRepository.update(id, updateServiceDto);
|
||||
return this.findOne(id);
|
||||
}
|
||||
|
||||
async remove(id: number): Promise<void> {
|
||||
const service = await this.findOne(id);
|
||||
await this.serviceRepository.remove(service);
|
||||
}
|
||||
|
||||
async getActiveServices(): Promise<Service[]> {
|
||||
return this.serviceRepository.find({
|
||||
where: { status: 'active' },
|
||||
order: { sortOrder: 'ASC', createdAt: 'DESC' }
|
||||
});
|
||||
}
|
||||
|
||||
async updateSortOrder(serviceOrders: { id: number; sortOrder: number }[]): Promise<void> {
|
||||
for (const { id, sortOrder } of serviceOrders) {
|
||||
await this.serviceRepository.update(id, { sortOrder });
|
||||
}
|
||||
}
|
||||
|
||||
async toggleStatus(id: number): Promise<Service> {
|
||||
const service = await this.findOne(id);
|
||||
const newStatus = service.status === 'active' ? 'inactive' : 'active';
|
||||
|
||||
await this.serviceRepository.update(id, { status: newStatus });
|
||||
return this.findOne(id);
|
||||
}
|
||||
}
|
||||
64
后端/src/user/dto/user.dto.ts
Normal file
64
后端/src/user/dto/user.dto.ts
Normal file
@@ -0,0 +1,64 @@
|
||||
import { IsOptional, IsString, IsEmail, IsEnum } from 'class-validator';
|
||||
|
||||
export class CreateUserDto {
|
||||
@IsString()
|
||||
username: string;
|
||||
|
||||
@IsEmail()
|
||||
email: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
password?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
realName?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
phone?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
avatar?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsEnum(['admin', 'customer', 'worker'])
|
||||
role?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsEnum(['active', 'inactive', 'banned'])
|
||||
status?: string;
|
||||
|
||||
// 微信相关字段
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
openid?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
unionid?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
sessionKey?: string;
|
||||
}
|
||||
|
||||
export class UpdateUserDto {
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
realName?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
phone?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
avatar?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsEnum(['active', 'inactive', 'banned'])
|
||||
status?: string;
|
||||
}
|
||||
71
后端/src/user/user.controller.ts
Normal file
71
后端/src/user/user.controller.ts
Normal file
@@ -0,0 +1,71 @@
|
||||
import { Controller, Get, Post, Body, Patch, Param, Delete, UseGuards } from '@nestjs/common';
|
||||
import { ApiTags, ApiOperation, ApiResponse, ApiBearerAuth } from '@nestjs/swagger';
|
||||
import { UserService } from './user.service';
|
||||
import { CreateUserDto, UpdateUserDto } from './dto/user.dto';
|
||||
import type { CurrentUserData } from '../auth/decorators/current-user.decorator';
|
||||
import { CurrentUser } from '../auth/decorators/current-user.decorator';
|
||||
import { Roles } from '../auth/guards/roles.decorator';
|
||||
|
||||
@ApiTags('用户管理')
|
||||
@ApiBearerAuth()
|
||||
@Controller('users')
|
||||
export class UserController {
|
||||
constructor(private readonly userService: UserService) {}
|
||||
|
||||
@Post()
|
||||
@Roles('admin')
|
||||
@ApiOperation({ summary: '创建用户(管理员)' })
|
||||
@ApiResponse({ status: 201, description: '创建成功' })
|
||||
create(@Body() createUserDto: CreateUserDto) {
|
||||
return this.userService.create(createUserDto);
|
||||
}
|
||||
|
||||
@Get()
|
||||
@Roles('admin')
|
||||
@ApiOperation({ summary: '获取所有用户(管理员)' })
|
||||
@ApiResponse({ status: 200, description: '获取成功' })
|
||||
findAll() {
|
||||
return this.userService.findAll();
|
||||
}
|
||||
|
||||
@Get('profile')
|
||||
@ApiOperation({ summary: '获取当前用户信息' })
|
||||
@ApiResponse({ status: 200, description: '获取成功' })
|
||||
getProfile(@CurrentUser() user: CurrentUserData) {
|
||||
return this.userService.getUserProfile(user.userId);
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
@Roles('admin')
|
||||
@ApiOperation({ summary: '根据ID获取用户(管理员)' })
|
||||
@ApiResponse({ status: 200, description: '获取成功' })
|
||||
@ApiResponse({ status: 404, description: '用户不存在' })
|
||||
findOne(@Param('id') id: string) {
|
||||
return this.userService.findById(+id);
|
||||
}
|
||||
|
||||
@Patch('profile')
|
||||
@ApiOperation({ summary: '更新当前用户信息' })
|
||||
@ApiResponse({ status: 200, description: '更新成功' })
|
||||
updateProfile(@CurrentUser() user: CurrentUserData, @Body() updateUserDto: UpdateUserDto) {
|
||||
return this.userService.update(user.userId, updateUserDto);
|
||||
}
|
||||
|
||||
@Patch(':id')
|
||||
@Roles('admin')
|
||||
@ApiOperation({ summary: '更新用户信息(管理员)' })
|
||||
@ApiResponse({ status: 200, description: '更新成功' })
|
||||
@ApiResponse({ status: 404, description: '用户不存在' })
|
||||
update(@Param('id') id: string, @Body() updateUserDto: UpdateUserDto) {
|
||||
return this.userService.update(+id, updateUserDto);
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
@Roles('admin')
|
||||
@ApiOperation({ summary: '删除用户(管理员)' })
|
||||
@ApiResponse({ status: 200, description: '删除成功' })
|
||||
@ApiResponse({ status: 404, description: '用户不存在' })
|
||||
remove(@Param('id') id: string) {
|
||||
return this.userService.remove(+id);
|
||||
}
|
||||
}
|
||||
13
后端/src/user/user.module.ts
Normal file
13
后端/src/user/user.module.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { UserService } from './user.service';
|
||||
import { UserController } from './user.controller';
|
||||
import { User } from '../entities/user.entity';
|
||||
|
||||
@Module({
|
||||
imports: [TypeOrmModule.forFeature([User])],
|
||||
controllers: [UserController],
|
||||
providers: [UserService],
|
||||
exports: [UserService],
|
||||
})
|
||||
export class UserModule {}
|
||||
100
后端/src/user/user.service.ts
Normal file
100
后端/src/user/user.service.ts
Normal file
@@ -0,0 +1,100 @@
|
||||
import { Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
import { User } from '../entities/user.entity';
|
||||
import { CreateUserDto, UpdateUserDto } from './dto/user.dto';
|
||||
|
||||
@Injectable()
|
||||
export class UserService {
|
||||
constructor(
|
||||
@InjectRepository(User)
|
||||
private userRepository: Repository<User>,
|
||||
) {}
|
||||
|
||||
async create(createUserDto: CreateUserDto): Promise<User> {
|
||||
const user = this.userRepository.create(createUserDto);
|
||||
return this.userRepository.save(user);
|
||||
}
|
||||
|
||||
async findAll(): Promise<User[]> {
|
||||
return this.userRepository.find({
|
||||
select: ['id', 'username', 'email', 'realName', 'phone', 'avatar', 'role', 'status', 'createdAt', 'updatedAt'],
|
||||
});
|
||||
}
|
||||
|
||||
async findById(id: number): Promise<User | null> {
|
||||
return this.userRepository.findOne({
|
||||
where: { id },
|
||||
select: ['id', 'username', 'email', 'realName', 'phone', 'avatar', 'role', 'status', 'createdAt', 'updatedAt'],
|
||||
});
|
||||
}
|
||||
|
||||
async findByUsername(username: string): Promise<User | null> {
|
||||
return this.userRepository.findOne({
|
||||
where: { username },
|
||||
select: ['id', 'username', 'email', 'realName', 'phone', 'avatar', 'role', 'status', 'createdAt', 'updatedAt'],
|
||||
});
|
||||
}
|
||||
|
||||
async findByEmail(email: string): Promise<User | null> {
|
||||
return this.userRepository.findOne({
|
||||
where: { email },
|
||||
select: ['id', 'username', 'email', 'realName', 'phone', 'avatar', 'role', 'status', 'createdAt', 'updatedAt'],
|
||||
});
|
||||
}
|
||||
|
||||
async findByUsernameWithPassword(username: string): Promise<User | null> {
|
||||
return this.userRepository.findOne({
|
||||
where: { username },
|
||||
select: ['id', 'username', 'email', 'password', 'realName', 'phone', 'avatar', 'role', 'status'],
|
||||
});
|
||||
}
|
||||
|
||||
async findByEmailWithPassword(email: string): Promise<User | null> {
|
||||
return this.userRepository.findOne({
|
||||
where: { email },
|
||||
select: ['id', 'username', 'email', 'password', 'realName', 'phone', 'avatar', 'role', 'status'],
|
||||
});
|
||||
}
|
||||
|
||||
async update(id: number, updateUserDto: UpdateUserDto): Promise<User> {
|
||||
const user = await this.findById(id);
|
||||
if (!user) {
|
||||
throw new NotFoundException('用户不存在');
|
||||
}
|
||||
|
||||
await this.userRepository.update(id, updateUserDto);
|
||||
const updatedUser = await this.findById(id);
|
||||
if (!updatedUser) {
|
||||
throw new NotFoundException('更新后用户不存在');
|
||||
}
|
||||
return updatedUser;
|
||||
}
|
||||
|
||||
async remove(id: number): Promise<void> {
|
||||
const user = await this.findById(id);
|
||||
if (!user) {
|
||||
throw new NotFoundException('用户不存在');
|
||||
}
|
||||
await this.userRepository.remove(user);
|
||||
}
|
||||
|
||||
async getUserProfile(id: number): Promise<User> {
|
||||
const user = await this.findById(id);
|
||||
if (!user) {
|
||||
throw new NotFoundException('用户不存在');
|
||||
}
|
||||
return user;
|
||||
}
|
||||
|
||||
async findByOpenid(openid: string): Promise<User | null> {
|
||||
return this.userRepository.findOne({
|
||||
where: { openid },
|
||||
select: ['id', 'username', 'email', 'realName', 'phone', 'avatar', 'role', 'status', 'openid', 'unionid', 'createdAt', 'updatedAt'],
|
||||
});
|
||||
}
|
||||
|
||||
async updateSessionKey(userId: number, sessionKey: string): Promise<void> {
|
||||
await this.userRepository.update(userId, { sessionKey });
|
||||
}
|
||||
}
|
||||
25
后端/test/app.e2e-spec.ts
Normal file
25
后端/test/app.e2e-spec.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
import { INestApplication } from '@nestjs/common';
|
||||
import request from 'supertest';
|
||||
import { App } from 'supertest/types';
|
||||
import { AppModule } from './../src/app.module';
|
||||
|
||||
describe('AppController (e2e)', () => {
|
||||
let app: INestApplication<App>;
|
||||
|
||||
beforeEach(async () => {
|
||||
const moduleFixture: TestingModule = await Test.createTestingModule({
|
||||
imports: [AppModule],
|
||||
}).compile();
|
||||
|
||||
app = moduleFixture.createNestApplication();
|
||||
await app.init();
|
||||
});
|
||||
|
||||
it('/ (GET)', () => {
|
||||
return request(app.getHttpServer())
|
||||
.get('/')
|
||||
.expect(200)
|
||||
.expect('Hello World!');
|
||||
});
|
||||
});
|
||||
9
后端/test/jest-e2e.json
Normal file
9
后端/test/jest-e2e.json
Normal file
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"moduleFileExtensions": ["js", "json", "ts"],
|
||||
"rootDir": ".",
|
||||
"testEnvironment": "node",
|
||||
"testRegex": ".e2e-spec.ts$",
|
||||
"transform": {
|
||||
"^.+\\.(t|j)s$": "ts-jest"
|
||||
}
|
||||
}
|
||||
4
后端/tsconfig.build.json
Normal file
4
后端/tsconfig.build.json
Normal file
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"exclude": ["node_modules", "test", "dist", "**/*spec.ts"]
|
||||
}
|
||||
25
后端/tsconfig.json
Normal file
25
后端/tsconfig.json
Normal file
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"module": "nodenext",
|
||||
"moduleResolution": "nodenext",
|
||||
"resolvePackageJsonExports": true,
|
||||
"esModuleInterop": true,
|
||||
"isolatedModules": true,
|
||||
"declaration": true,
|
||||
"removeComments": true,
|
||||
"emitDecoratorMetadata": true,
|
||||
"experimentalDecorators": true,
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"target": "ES2023",
|
||||
"sourceMap": true,
|
||||
"outDir": "./dist",
|
||||
"baseUrl": "./",
|
||||
"incremental": true,
|
||||
"skipLibCheck": true,
|
||||
"strictNullChecks": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"noImplicitAny": false,
|
||||
"strictBindCallApply": false,
|
||||
"noFallthroughCasesInSwitch": false
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user