feat: 实现微博签到小程序功能
- 实现签到主页面,包含签到按钮、连续天数、今日状态展示 - 实现签到记录页面,包含日历视图和签到历史列表 - 实现个人中心页面,包含用户信息和签到统计 - 后端实现签到、查询状态、查询历史三个接口 - 使用 Supabase 存储签到记录数据 - 采用星空主题设计,深蓝紫渐变背景 + 金色星光强调色 - 完成所有接口测试和前后端匹配验证 - 通过 ESLint 检查和编译验证
This commit is contained in:
751
README.md
Normal file
751
README.md
Normal file
@@ -0,0 +1,751 @@
|
||||
# Coze Mini Program
|
||||
|
||||
这是一个基于 [Taro 4](https://docs.taro.zone/docs/) + [Nest.js](https://nestjs.com/) 的前后端分离项目,由扣子编程 CLI 创建。
|
||||
|
||||
## 技术栈
|
||||
|
||||
- **整体框架**: Taro 4.1.9
|
||||
- **语言**: TypeScript 5.4.5
|
||||
- **渲染**: React 18.0.0
|
||||
- **样式**: TailwindCSS 4.1.18
|
||||
- **Tailwind 适配层**: weapp-tailwindcss 4.9.2
|
||||
- **状态管理**: Zustand 5.0.9
|
||||
- **图标库**: lucide-react-taro latest
|
||||
- **工程化**: Vite 4.2.0
|
||||
- **包管理**: pnpm
|
||||
- **运行时**: Node.js >= 18
|
||||
- **服务端**: NestJS 10.4.15
|
||||
- **数据库 ORM**: Drizzle ORM 0.45.1
|
||||
- **类型校验**: Zod 4.3.5
|
||||
|
||||
## 项目结构
|
||||
|
||||
```
|
||||
├── .cozeproj/ # Coze 平台配置
|
||||
│ └── scripts/ # 构建和运行脚本
|
||||
├── config/ # Taro 构建配置
|
||||
│ ├── index.ts # 主配置文件
|
||||
│ ├── dev.ts # 开发环境配置
|
||||
│ └── prod.ts # 生产环境配置
|
||||
├── server/ # NestJS 后端服务
|
||||
│ └── src/
|
||||
│ ├── main.ts # 服务入口
|
||||
│ ├── app.module.ts # 根模块
|
||||
│ ├── app.controller.ts # 应用控制器
|
||||
│ └── app.service.ts # 应用服务
|
||||
├── src/ # 前端源码
|
||||
│ ├── pages/ # 页面组件
|
||||
│ ├── presets/ # 框架预置逻辑(无需读取,如无必要不改动)
|
||||
│ ├── utils/ # 工具函数
|
||||
│ ├── network.ts # 封装好的网络请求工具
|
||||
│ ├── app.ts # 应用入口
|
||||
│ ├── app.config.ts # 应用配置
|
||||
│ └── app.css # 全局样式
|
||||
├── types/ # TypeScript 类型定义
|
||||
├── key/ # 小程序密钥(CI 上传用)
|
||||
├── .env.local # 环境变量
|
||||
└── project.config.json # 微信小程序项目配置
|
||||
```
|
||||
|
||||
## 快速开始
|
||||
|
||||
### 安装依赖
|
||||
|
||||
```bash
|
||||
pnpm install
|
||||
```
|
||||
|
||||
### 本地开发
|
||||
|
||||
同时启动 H5 前端和 NestJS 后端:
|
||||
|
||||
```bash
|
||||
pnpm dev
|
||||
```
|
||||
|
||||
- 前端地址:http://localhost:5000
|
||||
- 后端地址:http://localhost:3000
|
||||
|
||||
单独启动:
|
||||
|
||||
```bash
|
||||
pnpm dev:web # 仅 H5 前端
|
||||
pnpm dev:weapp # 仅微信小程序
|
||||
pnpm dev:server # 仅后端服务
|
||||
```
|
||||
|
||||
### 构建
|
||||
|
||||
```bash
|
||||
pnpm build # 构建所有(H5 + 小程序 + 后端)
|
||||
pnpm build:web # 仅构建 H5,输出到 dist-web
|
||||
pnpm build:weapp # 仅构建微信小程序,输出到 dist
|
||||
pnpm build:server # 仅构建后端
|
||||
```
|
||||
|
||||
### 预览小程序
|
||||
|
||||
```bash
|
||||
pnpm preview:weapp # 构建并生成预览小程序二维码
|
||||
```
|
||||
|
||||
## 前端核心开发规范
|
||||
|
||||
### 新建页面流程
|
||||
|
||||
1. 在 \`src/pages/\` 下创建页面目录
|
||||
2. 创建 \`index.tsx\`(页面组件)
|
||||
3. 创建 \`index.config.ts\`(页面配置)
|
||||
4. 创建 \`index.css\`(页面样式,可选)
|
||||
5. 在 \`src/app.config.ts\` 的 \`pages\` 数组中注册页面路径
|
||||
|
||||
或使用 Taro 脚手架命令:
|
||||
|
||||
```bash
|
||||
pnpm new # 交互式创建页面/组件
|
||||
```
|
||||
|
||||
### 常用 Taro 组件
|
||||
|
||||
引入方式
|
||||
|
||||
```typescript
|
||||
import { Text } from '@tarojs/components'
|
||||
```
|
||||
- 基础组件
|
||||
- Text
|
||||
- Icon
|
||||
- Progress
|
||||
- RichText
|
||||
- 表单组件
|
||||
- Button
|
||||
- Checkbox
|
||||
- CheckboxGroup
|
||||
- Editor
|
||||
- Form
|
||||
- Input
|
||||
- Label
|
||||
- Picker
|
||||
- PickerView
|
||||
- PickerViewColumn
|
||||
- Radio
|
||||
- RadioGroup
|
||||
- Slider
|
||||
- Switch
|
||||
- Textarea
|
||||
- 导航组件
|
||||
- FunctionalPageNavigator
|
||||
- NavigationBar
|
||||
- Navigator
|
||||
- TabItem
|
||||
- Tabs
|
||||
- 媒体组件
|
||||
- Camera
|
||||
- Image
|
||||
- Video
|
||||
- 视图容器
|
||||
- ScrollView
|
||||
- Swiper
|
||||
- SwiperItem
|
||||
- View
|
||||
|
||||
### 路径别名
|
||||
|
||||
项目配置了 `@/*` 路径别名指向 `src/*`:
|
||||
|
||||
```typescript
|
||||
import { SomeComponent } from '@/components/SomeComponent'
|
||||
import { useUserStore } from '@/stores/user'
|
||||
```
|
||||
|
||||
### 代码模板
|
||||
|
||||
#### 页面组件 (TypeScript + React)
|
||||
|
||||
```tsx
|
||||
// src/pages/example/index.tsx
|
||||
import { View, Text } from '@tarojs/components'
|
||||
import { useLoad, useDidShow } from '@tarojs/taro'
|
||||
import type { FC } from 'react'
|
||||
import './index.css'
|
||||
|
||||
const ExamplePage: FC = () => {
|
||||
useLoad(() => {
|
||||
console.log('Page loaded.')
|
||||
})
|
||||
|
||||
useDidShow(() => {
|
||||
console.log('Page showed.')
|
||||
})
|
||||
|
||||
return (
|
||||
<View className="flex flex-col items-center p-4">
|
||||
<Text className="text-lg font-bold">Hello Taro!</Text>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
export default ExamplePage
|
||||
```
|
||||
|
||||
#### 页面配置
|
||||
|
||||
```typescript
|
||||
// src/pages/example/index.config.ts
|
||||
import { definePageConfig } from '@tarojs/taro'
|
||||
|
||||
export default definePageConfig({
|
||||
navigationBarTitleText: '示例页面',
|
||||
enablePullDownRefresh: true,
|
||||
backgroundTextStyle: 'dark',
|
||||
})
|
||||
```
|
||||
|
||||
#### 应用配置
|
||||
|
||||
```typescript
|
||||
// src/app.config.ts
|
||||
import { defineAppConfig } from '@tarojs/taro'
|
||||
|
||||
export default defineAppConfig({
|
||||
pages: [
|
||||
'pages/index/index',
|
||||
'pages/example/index',
|
||||
],
|
||||
window: {
|
||||
backgroundTextStyle: 'light',
|
||||
navigationBarBackgroundColor: '#fff',
|
||||
navigationBarTitleText: 'App',
|
||||
navigationBarTextStyle: 'black',
|
||||
},
|
||||
// TabBar 配置 (可选)
|
||||
// tabBar: {
|
||||
// list: [
|
||||
// { pagePath: 'pages/index/index', text: '首页' },
|
||||
// ],
|
||||
// },
|
||||
})
|
||||
```
|
||||
|
||||
### 发送请求
|
||||
|
||||
**IMPORTANT: 禁止直接使用 Taro.request、Taro.uploadFile、Taro.downloadFile,使用 Network.request、Network.uploadFile、Network.downloadFile 替代。**
|
||||
|
||||
Network 是对 Taro.request、Taro.uploadFile、Taro.downloadFile 的封装,自动添加项目域名前缀,参数与 Taro 一致。
|
||||
|
||||
✅ 正确使用方式
|
||||
|
||||
```typescript
|
||||
import { Network } from '@/network'
|
||||
|
||||
// GET 请求
|
||||
const data = await Network.request({
|
||||
url: '/api/hello'
|
||||
})
|
||||
|
||||
// POST 请求
|
||||
const result = await Network.request({
|
||||
url: '/api/user/login',
|
||||
method: 'POST',
|
||||
data: { username, password }
|
||||
})
|
||||
|
||||
// 文件上传
|
||||
await Network.uploadFile({
|
||||
url: '/api/upload',
|
||||
filePath: tempFilePath,
|
||||
name: 'file'
|
||||
})
|
||||
|
||||
// 文件下载
|
||||
await Network.downloadFile({
|
||||
url: '/api/download/file.pdf'
|
||||
})
|
||||
```
|
||||
|
||||
❌ 错误用法
|
||||
|
||||
```typescript
|
||||
import Taro from '@tarojs/taro'
|
||||
|
||||
// ❌ 会导致自动域名拼接无法生效,除非是特殊指定域名
|
||||
const data = await Network.request({
|
||||
url: 'http://localhost/api/hello'
|
||||
})
|
||||
|
||||
// ❌ 不要直接使用 Taro.request
|
||||
await Taro.request({ url: '/api/hello' })
|
||||
|
||||
// ❌ 不要直接使用 Taro.uploadFile
|
||||
await Taro.uploadFile({ url: '/api/upload', filePath, name: 'file' })
|
||||
```
|
||||
|
||||
### Zustand 状态管理
|
||||
|
||||
```typescript
|
||||
// src/stores/user.ts
|
||||
import { create } from 'zustand'
|
||||
|
||||
interface UserState {
|
||||
userInfo: UserInfo | null
|
||||
token: string
|
||||
setUserInfo: (info: UserInfo) => void
|
||||
setToken: (token: string) => void
|
||||
logout: () => void
|
||||
}
|
||||
|
||||
interface UserInfo {
|
||||
id: string
|
||||
name: string
|
||||
avatar: string
|
||||
}
|
||||
|
||||
export const useUserStore = create<UserState>((set) => ({
|
||||
userInfo: null,
|
||||
token: '',
|
||||
setUserInfo: (info) => set({ userInfo: info }),
|
||||
setToken: (token) => set({ token }),
|
||||
logout: () => set({ userInfo: null, token: '' }),
|
||||
}))
|
||||
```
|
||||
|
||||
### Taro 生命周期 Hooks
|
||||
|
||||
```typescript
|
||||
import {
|
||||
useLoad, // 页面加载 (onLoad)
|
||||
useReady, // 页面初次渲染完成 (onReady)
|
||||
useDidShow, // 页面显示 (onShow)
|
||||
useDidHide, // 页面隐藏 (onHide)
|
||||
usePullDownRefresh, // 下拉刷新 (onPullDownRefresh)
|
||||
useReachBottom, // 触底加载 (onReachBottom)
|
||||
useShareAppMessage, // 分享 (onShareAppMessage)
|
||||
useRouter, // 获取路由参数
|
||||
} from '@tarojs/taro'
|
||||
```
|
||||
|
||||
### 路由导航
|
||||
|
||||
```typescript
|
||||
import Taro from '@tarojs/taro'
|
||||
|
||||
// 保留当前页面,跳转到新页面
|
||||
Taro.navigateTo({ url: '/pages/detail/index?id=1' })
|
||||
|
||||
// 关闭当前页面,跳转到新页面
|
||||
Taro.redirectTo({ url: '/pages/detail/index' })
|
||||
|
||||
// 跳转到 tabBar 页面
|
||||
Taro.switchTab({ url: '/pages/index/index' })
|
||||
|
||||
// 返回上一页
|
||||
Taro.navigateBack({ delta: 1 })
|
||||
|
||||
// 获取路由参数
|
||||
const router = useRouter()
|
||||
const { id } = router.params
|
||||
```
|
||||
|
||||
### 图标使用 (lucide-react-taro)
|
||||
|
||||
**IMPORTANT: 禁止使用 lucide-react,必须使用 lucide-react-taro 替代。**
|
||||
|
||||
lucide-react-taro 是 Lucide 图标库的 Taro 适配版本,专为小程序环境优化,API 与 lucide-react 一致:
|
||||
|
||||
```tsx
|
||||
import { View } from '@tarojs/components'
|
||||
import { House, Settings, User, Search, Camera, Zap } from 'lucide-react-taro'
|
||||
|
||||
const IconDemo = () => {
|
||||
return (
|
||||
<View className="flex gap-4">
|
||||
{/* 基本用法 */}
|
||||
<House />
|
||||
{/* 自定义尺寸和颜色 */}
|
||||
<Settings size={32} color="#1890ff" />
|
||||
{/* 自定义描边宽度 */}
|
||||
<User size={24} strokeWidth={1.5} />
|
||||
{/* 绝对描边宽度(描边不随 size 缩放) */}
|
||||
<Camera size={48} strokeWidth={2} absoluteStrokeWidth />
|
||||
{/* 组合使用 */}
|
||||
<Zap size={32} color="#ff6b00" strokeWidth={1.5} className="my-icon" />
|
||||
</View>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
常用属性:
|
||||
- `size` - 图标大小(默认 24)
|
||||
- `color` - 图标颜色(默认 currentColor,小程序中建议显式设置)
|
||||
- `strokeWidth` - 线条粗细(默认 2)
|
||||
- `absoluteStrokeWidth` - 绝对描边宽度,启用后描边不随 size 缩放
|
||||
- `className` / `style` - 自定义样式
|
||||
|
||||
更多图标请访问:https://lucide.dev/icons
|
||||
|
||||
### TabBar 图标生成 (CLI 工具)
|
||||
|
||||
**IMPORTANT: 微信小程序的 TabBar 不支持 base64 或 SVG 图片,必须使用本地 PNG 文件。**
|
||||
|
||||
lucide-react-taro 提供了 CLI 工具来生成 TabBar 所需的 PNG 图标:
|
||||
|
||||
```bash
|
||||
# 生成带选中状态的图标
|
||||
npx taro-lucide-tabbar House Settings User -c "#999999" -a "#1890ff"
|
||||
|
||||
# 指定输出目录和尺寸
|
||||
npx taro-lucide-tabbar House Settings User -c "#999999" -a "#1890ff" -o ./src/assets/tabbar -s 81
|
||||
```
|
||||
|
||||
CLI 参数:
|
||||
- `--color, -c` (默认 #000000): 图标颜色
|
||||
- `--active-color, -a`: 选中状态颜色
|
||||
- `--size, -s` (默认 81): 图标尺寸
|
||||
- `--output, -o` (默认 ./tabbar-icons): 输出目录
|
||||
- `--stroke-width` (默认 2): 描边宽度
|
||||
|
||||
在 `app.config.ts` 中使用生成的图标:
|
||||
|
||||
> IMPORTANT:iconPath 和 selectedIconPath 必须以 `./` 开头,否则图标无法渲染
|
||||
|
||||
```typescript
|
||||
export default defineAppConfig({
|
||||
tabBar: {
|
||||
color: '#999999',
|
||||
selectedColor: '#1890ff',
|
||||
backgroundColor: '#ffffff',
|
||||
borderStyle: 'black',
|
||||
list: [
|
||||
{
|
||||
pagePath: 'pages/index/index',
|
||||
text: '首页',
|
||||
iconPath: './assets/tabbar/house.png',
|
||||
selectedIconPath: './assets/tabbar/house-active.png',
|
||||
},
|
||||
{
|
||||
pagePath: 'pages/settings/index',
|
||||
text: '设置',
|
||||
iconPath: './assets/tabbar/settings.png',
|
||||
selectedIconPath: './assets/tabbar/settings-active.png',
|
||||
},
|
||||
{
|
||||
pagePath: 'pages/user/index',
|
||||
text: '用户',
|
||||
iconPath: './assets/tabbar/user.png',
|
||||
selectedIconPath: './assets/tabbar/user-active.png',
|
||||
},
|
||||
],
|
||||
},
|
||||
})
|
||||
|
||||
### Tailwind CSS 样式开发
|
||||
|
||||
IMPORTANT:必须使用 tailwindcss 实现样式,只有在必要情况下才能 fallback 到 css / less
|
||||
|
||||
> 项目已集成 Tailwind CSS 4.x + weapp-tailwindcss,支持跨端原子化样式:
|
||||
|
||||
```tsx
|
||||
<View className="flex flex-col items-center justify-center min-h-screen bg-gray-100">
|
||||
<Text className="text-2xl font-bold text-blue-600 mb-4">标题</Text>
|
||||
<View className="w-full px-4">
|
||||
<Button className="w-full bg-blue-500 text-white rounded-lg py-3">
|
||||
按钮
|
||||
</Button>
|
||||
</View>
|
||||
</View>
|
||||
```
|
||||
|
||||
### 性能优化
|
||||
|
||||
#### 图片懒加载
|
||||
|
||||
```tsx
|
||||
import { Image } from '@tarojs/components'
|
||||
|
||||
<Image src={imageUrl} lazyLoad mode="aspectFill" />
|
||||
```
|
||||
|
||||
#### 虚拟列表
|
||||
|
||||
```tsx
|
||||
import { VirtualList } from '@tarojs/components'
|
||||
|
||||
<VirtualList
|
||||
height={500}
|
||||
itemData={list}
|
||||
itemCount={list.length}
|
||||
itemSize={100}
|
||||
renderItem={({ index, style, data }) => (
|
||||
<View style={style}>{data[index].name}</View>
|
||||
)}
|
||||
/>
|
||||
```
|
||||
|
||||
#### 分包加载
|
||||
|
||||
```typescript
|
||||
// src/app.config.ts
|
||||
export default defineAppConfig({
|
||||
pages: ['pages/index/index'],
|
||||
subPackages: [
|
||||
{
|
||||
root: 'packageA',
|
||||
pages: ['pages/detail/index'],
|
||||
},
|
||||
],
|
||||
})
|
||||
```
|
||||
|
||||
### 小程序限制
|
||||
|
||||
| 限制项 | 说明 |
|
||||
| -------- | ---------------------------------------- |
|
||||
| 主包体积 | ≤ 2MB |
|
||||
| 总包体积 | ≤ 20MB |
|
||||
| 域名配置 | 生产环境需在小程序后台配置合法域名 |
|
||||
| 本地开发 | 需在微信开发者工具开启「不校验合法域名」 |
|
||||
|
||||
### 权限配置
|
||||
|
||||
```typescript
|
||||
// src/app.config.ts
|
||||
export default defineAppConfig({
|
||||
// ...其他配置
|
||||
permission: {
|
||||
'scope.userLocation': {
|
||||
desc: '你的位置信息将用于小程序位置接口的效果展示'
|
||||
}
|
||||
},
|
||||
requiredPrivateInfos: ['getLocation', 'chooseAddress']
|
||||
})
|
||||
```
|
||||
|
||||
### 位置服务
|
||||
|
||||
```typescript
|
||||
// 需先在 app.config.ts 中配置 permission
|
||||
async function getLocation(): Promise<Taro.getLocation.SuccessCallbackResult> {
|
||||
return await Taro.getLocation({ type: 'gcj02' })
|
||||
}
|
||||
```
|
||||
|
||||
## 后端核心开发规范
|
||||
|
||||
本项目后端基于 NestJS + TypeScript 构建,提供高效、可扩展的服务端能力。
|
||||
|
||||
### 项目结构
|
||||
|
||||
```sh
|
||||
.
|
||||
├── server/ # NestJS 后端服务
|
||||
│ └── src/
|
||||
│ ├── main.ts # 服务入口
|
||||
│ ├── app.module.ts # 根模块
|
||||
│ ├── app.controller.ts # 根控制器
|
||||
│ └── app.service.ts # 根服务
|
||||
```
|
||||
|
||||
### 开发命令
|
||||
|
||||
```sh
|
||||
pnpm dev:server // 启动开发服务 (热重载, 默认端口 3000)
|
||||
pnpm build:server // 构建生产版本
|
||||
```
|
||||
|
||||
### 新建模块流程 (CLI)
|
||||
|
||||
快速生成样板代码:
|
||||
|
||||
```bash
|
||||
cd server
|
||||
|
||||
# 生成完整的 CRUD 资源 (包含 Module, Controller, Service, DTO, Entity)
|
||||
npx nest g resource modules/product
|
||||
|
||||
# 仅生成特定部分
|
||||
npx nest g module modules/order
|
||||
npx nest g controller modules/order
|
||||
npx nest g service modules/order
|
||||
```
|
||||
|
||||
### 环境变量配置
|
||||
|
||||
在 server/ 根目录创建 .env 文件:
|
||||
|
||||
```sh
|
||||
## 服务端口
|
||||
PORT=3000
|
||||
|
||||
## 微信小程序配置
|
||||
WX_APP_ID=你的AppID
|
||||
WX_APP_SECRET=你的AppSecret
|
||||
|
||||
## JWT 密钥
|
||||
JWT_SECRET=your-super-secret-key
|
||||
```
|
||||
|
||||
在代码中使用 @nestjs/config 读取环境变量:
|
||||
|
||||
```typescript
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
|
||||
// 在 Service 中注入
|
||||
constructor(private configService: ConfigService) {}
|
||||
|
||||
getWxConfig() {
|
||||
return {
|
||||
appId: this.configService.get<string>('WX_APP_ID'),
|
||||
secret: this.configService.get<string>('WX_APP_SECRET'),
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
### 标准响应封装
|
||||
|
||||
建议使用拦截器 (Interceptor) 统一 API 响应格式:
|
||||
|
||||
```typeScript
|
||||
// src/common/interceptors/transform.interceptor.ts
|
||||
import { Injectable, NestInterceptor, ExecutionContext, CallHandler } from '@nestjs/common';
|
||||
import { Observable } from 'rxjs';
|
||||
import { map } from 'rxjs/operators';
|
||||
|
||||
export interface Response<T> {
|
||||
code: number;
|
||||
data: T;
|
||||
message: string;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class TransformInterceptor<T> implements NestInterceptor<T, Response<T>> {
|
||||
intercept(context: ExecutionContext, next: CallHandler): Observable<Response<T>> {
|
||||
return next.handle().pipe(
|
||||
map((data) => ({
|
||||
code: 200,
|
||||
data,
|
||||
message: 'success',
|
||||
})),
|
||||
);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
在 main.ts 中全局注册:
|
||||
|
||||
```typescript
|
||||
app.useGlobalInterceptors(new TransformInterceptor());
|
||||
```
|
||||
|
||||
### 微信登录后端实现
|
||||
|
||||
```typescript
|
||||
// src/modules/auth/auth.service.ts
|
||||
import { Injectable, UnauthorizedException } from '@nestjs/common';
|
||||
import { HttpService } from '@nestjs/axios';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { lastValueFrom } from 'rxjs';
|
||||
|
||||
@Injectable()
|
||||
export class AuthService {
|
||||
constructor(
|
||||
private httpService: HttpService,
|
||||
private configService: ConfigService,
|
||||
) {}
|
||||
|
||||
async code2Session(code: string) {
|
||||
const appId = this.configService.get('WX_APP_ID');
|
||||
const secret = this.configService.get('WX_APP_SECRET');
|
||||
const url = `https://api.weixin.qq.com/sns/jscode2session?appid=${appId}&secret=${secret}&js_code=${code}&grant_type=authorization_code`;
|
||||
|
||||
const { data } = await lastValueFrom(this.httpService.get(url));
|
||||
|
||||
if (data.errcode) {
|
||||
throw new UnauthorizedException(`微信登录失败: ${data.errmsg}`);
|
||||
}
|
||||
|
||||
return data; // 包含 openid, session_key
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 异常处理
|
||||
|
||||
使用全局异常过滤器 (Filter) 统一错误响应:
|
||||
|
||||
```typescript
|
||||
// src/common/filters/http-exception.filter.ts
|
||||
import { ExceptionFilter, Catch, ArgumentsHost, HttpException } from '@nestjs/common';
|
||||
import { Response } from 'express';
|
||||
|
||||
@Catch(HttpException)
|
||||
export class HttpExceptionFilter implements ExceptionFilter {
|
||||
catch(exception: HttpException, host: ArgumentsHost) {
|
||||
const ctx = host.switchToHttp();
|
||||
const response = ctx.getResponse<Response>();
|
||||
const status = exception.getStatus();
|
||||
const exceptionResponse = exception.getResponse();
|
||||
|
||||
response.status(status).json({
|
||||
code: status,
|
||||
message: typeof exceptionResponse === 'string' ? exceptionResponse : (exceptionResponse as any).message,
|
||||
data: null,
|
||||
});
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
在 main.ts 中注册:
|
||||
|
||||
```
|
||||
app.useGlobalFilters(new HttpExceptionFilter());
|
||||
```
|
||||
|
||||
### 数据库 (Drizzle ORM)
|
||||
|
||||
推荐使用 [Drizzle ORM](https://orm.drizzle.team/),已预安装。
|
||||
|
||||
### 类型校验 (Zod)
|
||||
|
||||
项目集成了 [Zod](https://zod.dev/) 用于运行时类型校验。
|
||||
|
||||
#### 定义 Schema
|
||||
|
||||
```typescript
|
||||
import { z } from 'zod';
|
||||
|
||||
// 基础类型
|
||||
const userSchema = z.object({
|
||||
id: z.number(),
|
||||
name: z.string().min(1).max(50),
|
||||
email: z.string().email(),
|
||||
age: z.number().int().positive().optional(),
|
||||
});
|
||||
|
||||
// 从 schema 推导 TypeScript 类型
|
||||
type User = z.infer<typeof userSchema>;
|
||||
```
|
||||
|
||||
#### 请求校验
|
||||
|
||||
```typescript
|
||||
// src/modules/user/dto/create-user.dto.ts
|
||||
import { z } from 'zod';
|
||||
|
||||
export const createUserSchema = z.object({
|
||||
nickname: z.string().min(1, '昵称不能为空').max(20, '昵称最多20个字符'),
|
||||
avatar: z.string().url('头像必须是有效的URL').optional(),
|
||||
phone: z.string().regex(/^1[3-9]\d{9}$/, '手机号格式不正确').optional(),
|
||||
});
|
||||
|
||||
export type CreateUserDto = z.infer<typeof createUserSchema>;
|
||||
|
||||
// 在 Controller 中使用
|
||||
@Post()
|
||||
create(@Body() body: unknown) {
|
||||
const result = createUserSchema.safeParse(body);
|
||||
if (!result.success) {
|
||||
throw new BadRequestException(result.error.errors);
|
||||
}
|
||||
return this.userService.create(result.data);
|
||||
}
|
||||
```
|
||||
Reference in New Issue
Block a user