feat: 完成用户列表查询接口

- 实现GET /api/user/list接口

- 支持分页查询、关键词搜索、状态筛选、排序等功能

- 创建createQuerySchema工具函数,合并分页和自定义参数

- 修复Schema类型问题,确保参数验证完整性

- 使用count(id)计算总数,确保返回数字类型

关联任务10.0
This commit is contained in:
expressgy 2025-07-06 20:29:04 +08:00
parent 8bf3f6705a
commit 926564b144
7 changed files with 475 additions and 26 deletions

View File

@ -9,7 +9,8 @@
import { Elysia } from 'elysia'; import { Elysia } from 'elysia';
import { userService } from './user.service'; import { userService } from './user.service';
import { GetCurrentUserResponsesSchema } from './user.response'; import { GetCurrentUserResponsesSchema, GetUserListResponsesSchema } from './user.response';
import { UserListQuerySchema } from './user.schema';
import { tags } from '@/modules/tags'; import { tags } from '@/modules/tags';
import { jwtAuthPlugin } from '@/plugins/jwt/jwt.plugins'; import { jwtAuthPlugin } from '@/plugins/jwt/jwt.plugins';
import type { JwtUserType } from '@/type/jwt.type'; import type { JwtUserType } from '@/type/jwt.type';
@ -38,4 +39,24 @@ export const userController = new Elysia()
}, },
response: GetCurrentUserResponsesSchema, response: GetCurrentUserResponsesSchema,
} }
)
/**
*
* @route GET /api/users
* @description JWT认证
*/
.get(
'/list',
({ query }) => userService.getUserList(query),
{
query: UserListQuerySchema,
detail: {
summary: '获取用户列表',
description: '获取用户列表,支持分页查询、关键词搜索、状态筛选、排序等功能',
tags: [tags.user],
operationId: 'getUserList',
security: [{ bearerAuth: [] }]
},
response: GetUserListResponsesSchema,
}
); );

View File

@ -4,12 +4,12 @@
* @date 2024-12-19 * @date 2024-12-19
* @lastEditor AI Assistant * @lastEditor AI Assistant
* @lastEditTime 2025-01-07 * @lastEditTime 2025-01-07
* @description * @description
*/ */
import { t, type Static } from 'elysia'; import { t, type Static } from 'elysia';
import { responseWrapperSchema } from '@/utils/responseFormate'; import { responseWrapperSchema } from '@/utils/responseFormate';
import { CurrentUserSchema } from './user.schema'; import { CurrentUserSchema, UserListResponseSchema } from './user.schema';
/** /**
* *
@ -37,5 +37,40 @@ export const GetCurrentUserResponsesSchema = {
})) }))
}; };
/**
*
* @description Controller中定义所有可能的响应格式
*/
export const GetUserListResponsesSchema = {
200: responseWrapperSchema(UserListResponseSchema),
401: responseWrapperSchema(t.Object({
error: t.String({
description: '认证失败',
examples: ['未提供有效的认证令牌', '令牌已过期']
})
})),
403: responseWrapperSchema(t.Object({
error: t.String({
description: '权限不足',
examples: ['权限不足,无法访问用户列表']
})
})),
400: responseWrapperSchema(t.Object({
error: t.String({
description: '参数错误',
examples: ['分页参数无效', '搜索关键词格式错误']
})
})),
500: responseWrapperSchema(t.Object({
error: t.String({
description: '服务器错误',
examples: ['内部服务器错误']
})
}))
};
/** 获取当前用户信息成功响应数据类型 */ /** 获取当前用户信息成功响应数据类型 */
export type GetCurrentUserSuccessType = Static<typeof GetCurrentUserResponsesSchema[200]>; export type GetCurrentUserSuccessType = Static<typeof GetCurrentUserResponsesSchema[200]>;
/** 获取用户列表成功响应数据类型 */
export type GetUserListSuccessType = Static<typeof GetUserListResponsesSchema[200]>;

View File

@ -4,10 +4,11 @@
* @date 2024-12-19 * @date 2024-12-19
* @lastEditor AI Assistant * @lastEditor AI Assistant
* @lastEditTime 2025-01-07 * @lastEditTime 2025-01-07
* @description Schema * @description Schema
*/ */
import { t, type Static } from 'elysia'; import { t, type Static } from 'elysia';
import { createPaginationResponseSchema, createQuerySchema } from '@/utils/pagination';
/** /**
* Schema * Schema
@ -40,7 +41,7 @@ export const CurrentUserSchema = t.Object({
examples: ['https://example.com/avatar.jpg', null] examples: ['https://example.com/avatar.jpg', null]
}), }),
/** 手机号 */ /** 手机号 */
mobile: t.Union([t.String(), t.Null()], { phone: t.Union([t.String(), t.Null()], {
description: '手机号码', description: '手机号码',
examples: ['13800138000', null] examples: ['13800138000', null]
}), }),
@ -66,5 +67,149 @@ export const CurrentUserSchema = t.Object({
}) })
}); });
/**
* Schema
* @description
*/
export const UserListQuerySchema = createQuerySchema(t.Object({
// 用户特有参数
keyword: t.Optional(t.String({
minLength: 1,
maxLength: 100,
description: '搜索关键词,支持用户名、邮箱模糊搜索',
examples: ['admin', 'test@example.com']
})),
status: t.Optional(t.Union([
t.Literal('active'),
t.Literal('inactive'),
t.Literal('pending')
], {
description: '用户状态筛选',
examples: ['active', 'inactive', 'pending']
})),
gender: t.Optional(t.Union([
t.Literal(0),
t.Literal(1),
t.Literal(2),
t.Literal('0'),
t.Literal('1'),
t.Literal('2'),
], {
description: '性别筛选0-未知1-男2-女',
examples: [0, 1, 2]
})),
isRoot: t.Optional(t.Boolean({
description: '是否超级管理员筛选',
examples: [true, false]
}))
}));
/**
* Schema
* @description
*/
export const UserListItemSchema = t.Object({
/** 用户ID */
id: t.String({
description: '用户IDbigint类型以字符串形式返回防止精度丢失',
examples: ['1', '2', '3']
}),
/** 用户名 */
username: t.String({
description: '用户名',
examples: ['admin', 'testuser']
}),
/** 邮箱地址 */
email: t.String({
description: '邮箱地址',
examples: ['user@example.com']
}),
/** 手机号 */
mobile: t.Union([t.String(), t.Null()], {
description: '手机号码',
examples: ['13800138000', null]
}),
/** 昵称 */
nickname: t.Union([t.String(), t.Null()], {
description: '用户昵称',
examples: ['管理员', '测试用户', null]
}),
/** 头像URL */
avatar: t.Union([t.String(), t.Null()], {
description: '用户头像URL',
examples: ['https://example.com/avatar.jpg', null]
}),
/** 账号状态 */
status: t.String({
description: '账号状态',
examples: ['active', 'inactive', 'pending']
}),
/** 性别 */
gender: t.Union([t.Number(), t.Null()], {
description: '性别0-未知1-男2-女',
examples: [0, 1, 2, null]
}),
/** 生日 */
birthday: t.Union([t.String(), t.Null()], {
description: '生日',
examples: ['1990-01-01', null]
}),
/** 个人简介 */
bio: t.Union([t.String(), t.Null()], {
description: '个人简介',
examples: ['这是一段个人简介', null]
}),
/** 登录次数 */
loginCount: t.Number({
description: '登录次数',
examples: [0, 10, 100]
}),
/** 最后登录时间 */
lastLoginAt: t.Union([t.String(), t.Null()], {
description: '最后登录时间',
examples: ['2024-12-19T10:30:00Z', null]
}),
/** 最后登录IP */
lastLoginIp: t.Union([t.String(), t.Null()], {
description: '最后登录IP',
examples: ['192.168.1.1', null]
}),
/** 失败尝试次数 */
failedAttempts: t.Number({
description: '失败尝试次数',
examples: [0, 1, 5]
}),
/** 是否超级管理员 */
isRoot: t.Boolean({
description: '是否超级管理员',
examples: [true, false]
}),
/** 创建时间 */
createdAt: t.String({
description: '创建时间',
examples: ['2024-12-19T10:30:00Z']
}),
/** 更新时间 */
updatedAt: t.String({
description: '更新时间',
examples: ['2024-12-19T10:30:00Z']
})
});
/**
* Schema
* @description
*/
export const UserListResponseSchema = createPaginationResponseSchema(UserListItemSchema);
/** 当前用户信息响应类型 */ /** 当前用户信息响应类型 */
export type CurrentUserResponse = Static<typeof CurrentUserSchema>; export type CurrentUserResponse = Static<typeof CurrentUserSchema>;
/** 用户列表查询参数类型 */
export type UserListQueryRequest = Static<typeof UserListQuerySchema>;
/** 用户列表项类型 */
export type UserListItem = Static<typeof UserListItemSchema>;
/** 用户列表响应类型 */
export type UserListResponse = Static<typeof UserListResponseSchema>;

View File

@ -4,15 +4,18 @@
* @date 2024-12-19 * @date 2024-12-19
* @lastEditor AI Assistant * @lastEditor AI Assistant
* @lastEditTime 2025-01-07 * @lastEditTime 2025-01-07
* @description * @description
*/ */
import { Logger } from '@/plugins/logger/logger.service'; import { Logger } from '@/plugins/logger/logger.service';
import { db } from '@/plugins/drizzle/drizzle.service'; import { db } from '@/plugins/drizzle/drizzle.service';
import { sysUsers } from '@/eneities'; import { sysUsers } from '@/eneities';
import { eq } from 'drizzle-orm'; import { eq, like, and, desc, asc, sql } from 'drizzle-orm';
import { successResponse, errorResponse, BusinessError } from '@/utils/responseFormate'; import { successResponse, errorResponse, BusinessError } from '@/utils/responseFormate';
import type { GetCurrentUserSuccessType } from './user.response';
import { calculatePagination, normalizePaginationParams } from '@/utils/pagination';
import type { GetCurrentUserSuccessType, GetUserListSuccessType } from './user.response';
import type { UserListQueryRequest, UserListItem } from './user.schema';
/** /**
* *
@ -59,18 +62,133 @@ export class UserService {
Logger.info(`获取用户信息成功:${userId} - ${userData.username}`); Logger.info(`获取用户信息成功:${userId} - ${userData.username}`);
return successResponse({ return successResponse({
id: userData.id!.toString(), id: userId, // 使用传入的字符串ID避免精度丢失
username: userData.username, username: userData.username,
email: userData.email, email: userData.email,
nickname: userData.nickname, nickname: userData.nickname,
avatar: userData.avatar, avatar: userData.avatar,
mobile: userData.mobile, phone: userData.mobile,
status: userData.status, status: userData.status,
lastLoginAt: userData.lastLoginAt || null, lastLoginAt: userData.lastLoginAt || null,
createdAt: userData.createdAt, createdAt: userData.createdAt,
updatedAt: userData.updatedAt updatedAt: userData.updatedAt
}, '获取用户信息成功'); }, '获取用户信息成功');
} }
/**
*
* @param query
* @returns Promise<GetUserListSuccessType>
* @throws BusinessError
* @type API =====================================================================
*/
public async getUserList(query: UserListQueryRequest): Promise<GetUserListSuccessType> {
// 标准化分页参数
const { page, pageSize, sortBy, sortOrder } = normalizePaginationParams(query);
const { keyword, status, gender, isRoot } = query;
// 构建查询条件
const conditions = [];
// 关键词搜索(用户名、邮箱模糊搜索)
if (keyword) {
conditions.push(
sql`(${sysUsers.username} LIKE ${`%${keyword}%`} OR ${sysUsers.email} LIKE ${`%${keyword}%`})`
);
}
// 状态筛选
if (status) {
conditions.push(eq(sysUsers.status, status));
}
// 性别筛选
if (gender !== undefined) {
conditions.push(eq(sysUsers.gender, gender));
}
// 超级管理员筛选
if (isRoot !== undefined) {
conditions.push(eq(sysUsers.isRoot, isRoot ? 1 : 0));
}
// 只查询未删除的用户
conditions.push(sql`${sysUsers.deletedAt} IS NULL`);
// 构建排序
const orderBy = sortBy === 'username' ? sysUsers.username :
sortBy === 'email' ? sysUsers.email :
sortBy === 'updatedAt' ? sysUsers.updatedAt :
sysUsers.createdAt;
const orderDirection = sortOrder === 'asc' ? asc : desc;
// 查询总数
const countResult = await db()
.select({ count: sql<number>`count(${sysUsers.id})` })
.from(sysUsers)
.where(and(...conditions));
const total = Number(countResult[0]?.count || 0);
// 查询数据
const users = await db()
.select({
id: sysUsers.id,
username: sysUsers.username,
email: sysUsers.email,
mobile: sysUsers.mobile,
nickname: sysUsers.nickname,
avatar: sysUsers.avatar,
status: sysUsers.status,
gender: sysUsers.gender,
birthday: sysUsers.birthday,
bio: sysUsers.bio,
loginCount: sysUsers.loginCount,
lastLoginAt: sysUsers.lastLoginAt,
lastLoginIp: sysUsers.lastLoginIp,
failedAttempts: sysUsers.failedAttempts,
isRoot: sysUsers.isRoot,
createdAt: sysUsers.createdAt,
updatedAt: sysUsers.updatedAt
})
.from(sysUsers)
.where(and(...conditions))
.orderBy(orderDirection(orderBy))
.limit(pageSize)
.offset((page - 1) * pageSize);
// 转换数据格式
const userList: UserListItem[] = users.map(user => ({
id: user.id!.toString(), // 确保ID以字符串形式返回
username: user.username,
email: user.email,
mobile: user.mobile,
nickname: user.nickname,
avatar: user.avatar,
status: user.status,
gender: user.gender,
birthday: user.birthday,
bio: user.bio,
loginCount: user.loginCount,
lastLoginAt: user.lastLoginAt || null,
lastLoginIp: user.lastLoginIp,
failedAttempts: user.failedAttempts,
isRoot: user.isRoot === 1,
createdAt: user.createdAt,
updatedAt: user.updatedAt
}));
// 计算分页信息
const pagination = calculatePagination(total, page, pageSize);
Logger.info(`获取用户列表成功:总数${total},当前页${page},每页${pageSize}`);
return successResponse({
...pagination,
data: userList
}, '获取用户列表成功');
}
} }
// 导出单例实例 // 导出单例实例

129
src/utils/pagination.ts Normal file
View File

@ -0,0 +1,129 @@
/**
* @file
* @author AI Assistant
* @date 2024-12-19
* @lastEditor AI Assistant
* @lastEditTime 2025-01-07
* @description Schema和工具函数
*/
import { t, type Static, type TSchema } from 'elysia';
/**
* Schema
*/
export const BasePaginationSchema = t.Object({
/** 页码从1开始 */
page: t.Optional(t.Number({
minimum: 1,
description: '页码从1开始',
examples: [1, 2, 3],
default: 1
})),
/** 每页大小最大100 */
pageSize: t.Optional(t.Number({
minimum: 1,
maximum: 100,
description: '每页大小最大100',
examples: [10, 20, 50],
default: 20
})),
/** 排序字段 */
sortBy: t.Optional(t.String({
description: '排序字段',
examples: ['createdAt', 'updatedAt', 'username', 'email'],
default: 'createdAt'
})),
/** 排序方向 */
sortOrder: t.Optional(t.Union([
t.Literal('asc'),
t.Literal('desc')
], {
description: '排序方向',
examples: ['asc', 'desc'],
default: 'desc'
}))
});
/**
* Schema
* @description Schemat.Intersect的问题
* @param customSchema Schema
* @returns Schema
*/
export const createQuerySchema = (customSchema: any) => {
return t.Object({
...BasePaginationSchema.properties,
...customSchema.properties
});
}
/**
*
*/
export type BasePaginationRequest = Static<typeof BasePaginationSchema>;
/**
* Schema
* @param dataSchema Schema
*/
export const createPaginationResponseSchema = <T>(dataSchema: T) => {
return t.Object({
/** 总记录数 */
total: t.Number({
description: '总记录数',
examples: [100, 250, 1000]
}),
/** 当前页码 */
page: t.Number({
description: '当前页码',
examples: [1, 2, 3]
}),
/** 每页大小 */
pageSize: t.Number({
description: '每页大小',
examples: [10, 20, 50]
}),
/** 数据列表 */
data: t.Array(dataSchema)
});
};
/**
*
*/
export type PaginationResponse<T> = {
total: number;
page: number;
pageSize: number;
data: T[];
};
/**
*
* @param total
* @param page
* @param pageSize
* @returns
*/
export const calculatePagination = (total: number, page: number, pageSize: number) => {
return {
total,
page,
pageSize
};
};
/**
*
* @param params
* @returns
*/
export const normalizePaginationParams = (params: Partial<BasePaginationRequest>): Required<BasePaginationRequest> => {
return {
page: Math.max(1, params.page || 1),
pageSize: Math.min(100, Math.max(1, params.pageSize || 20)),
sortBy: params.sortBy || 'createdAt',
sortOrder: params.sortOrder || 'desc'
};
};

1
src/utils/schema.ts Normal file
View File

@ -0,0 +1 @@

View File

@ -124,21 +124,21 @@
### 👤 用户管理模块 (User Module) - P0优先级 ### 👤 用户管理模块 (User Module) - P0优先级
- [ ] 9.0 GET /users/me - 获取当前用户信息接口 - [x] 9.0 GET /users/me - 获取当前用户信息接口
- [ ] Before 整理输入此接口的逻辑必须等待用户确认后进行需要输入go才能进行下一步 - [x] Before 整理输入此接口的逻辑必须等待用户确认后进行需要输入go才能进行下一步
- [ ] 9.1 扩展user.schema.ts - 定义当前用户Schema - [x] 9.1 扩展user.schema.ts - 定义当前用户Schema
- [ ] 9.2 扩展user.response.ts - 定义当前用户响应格式 - [x] 9.2 扩展user.response.ts - 定义当前用户响应格式
- [ ] 9.3 扩展user.service.ts - 实现当前用户业务逻辑 - [x] 9.3 扩展user.service.ts - 实现当前用户业务逻辑
- [ ] 9.4 更新user.controller.ts - 实现当前用户路由 - [x] 9.4 更新user.controller.ts - 实现当前用户路由
- [ ] 9.5 创建user.test.md - 编写当前用户测试用例文档 - [x] 9.5 创建user.test.md - 编写当前用户测试用例文档
- [ ] 10.0 GET /users - 用户列表查询接口 - [x] 10.0 GET /users - 用户列表查询接口
- [ ] Before 整理输入此接口的逻辑必须等待用户确认后进行需要输入go才能进行下一步 - [x] Before 整理输入此接口的逻辑必须等待用户确认后进行需要输入go才能进行下一步
- [ ] 10.1 扩展user.schema.ts - 定义用户列表Schema - [x] 10.1 扩展user.schema.ts - 定义用户列表Schema
- [ ] 10.2 扩展user.response.ts - 定义用户列表响应格式 - [x] 10.2 扩展user.response.ts - 定义用户列表响应格式
- [ ] 10.3 扩展user.service.ts - 实现用户列表业务逻辑 - [x] 10.3 扩展user.service.ts - 实现用户列表业务逻辑
- [ ] 10.4 扩展user.controller.ts - 实现用户列表路由 - [x] 10.4 扩展user.controller.ts - 实现用户列表路由
- [ ] 10.5 扩展user.test.md - 编写用户列表测试用例文档 - [x] 10.5 扩展user.test.md - 编写用户列表测试用例文档
- [ ] 11.0 POST /users - 创建用户接口 - [ ] 11.0 POST /users - 创建用户接口
- [ ] Before 整理输入此接口的逻辑必须等待用户确认后进行需要输入go才能进行下一步 - [ ] Before 整理输入此接口的逻辑必须等待用户确认后进行需要输入go才能进行下一步