feat(admin): add user management and system settings pages

Backend (user-service):
- Add admin user management APIs (list, search, statistics, detail)
- Add pagination and filtering support for user queries
- Add JWT token authentication for admin endpoints

Frontend (admin-client):
- Add UsersPage with user list, search, filters and statistics
- Add SettingsPage with admin profile, password change, system info
- Update App.tsx routes to use new pages

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
hailin 2026-01-25 08:58:35 -08:00
parent ed5dc49b4a
commit e0c2462017
15 changed files with 1013 additions and 8 deletions

View File

@ -6,6 +6,8 @@ import { DashboardPage } from './features/dashboard/presentation/pages/Dashboard
import { KnowledgePage } from './features/knowledge/presentation/pages/KnowledgePage';
import { ExperiencePage } from './features/experience/presentation/pages/ExperiencePage';
import { AnalyticsPage, ReportsPage, AuditPage } from './features/analytics';
import { UsersPage } from './features/users';
import { SettingsPage } from './features/settings';
function App() {
return (
@ -28,8 +30,8 @@ function App() {
<Route path="analytics" element={<AnalyticsPage />} />
<Route path="reports" element={<ReportsPage />} />
<Route path="audit" element={<AuditPage />} />
<Route path="users" element={<div className="p-6"></div>} />
<Route path="settings" element={<div className="p-6"></div>} />
<Route path="users" element={<UsersPage />} />
<Route path="settings" element={<SettingsPage />} />
</Route>
{/* 未匹配路由重定向 */}

View File

@ -0,0 +1 @@
export { SettingsPage } from './presentation/pages/SettingsPage';

View File

@ -0,0 +1,238 @@
import { useState } from 'react';
import {
Card,
Form,
Input,
Button,
Row,
Col,
Typography,
Descriptions,
Divider,
message,
Modal,
} from 'antd';
import {
UserOutlined,
LockOutlined,
SafetyOutlined,
InfoCircleOutlined,
} from '@ant-design/icons';
import { useAuth } from '../../../../shared/hooks/useAuth';
import api from '../../../../shared/utils/api';
const { Title, Text } = Typography;
export function SettingsPage() {
const { admin, logout } = useAuth();
const [changePasswordForm] = Form.useForm();
const [changingPassword, setChangingPassword] = useState(false);
const [passwordModalOpen, setPasswordModalOpen] = useState(false);
const handleChangePassword = async () => {
try {
const values = await changePasswordForm.validateFields();
if (values.newPassword !== values.confirmPassword) {
message.error('两次输入的密码不一致');
return;
}
setChangingPassword(true);
await api.post('/admin/change-password', {
oldPassword: values.oldPassword,
newPassword: values.newPassword,
});
message.success('密码修改成功,请重新登录');
setPasswordModalOpen(false);
changePasswordForm.resetFields();
// Logout after password change
setTimeout(() => {
logout();
}, 1500);
} catch (error: unknown) {
const err = error as { response?: { data?: { message?: string } } };
message.error(err.response?.data?.message || '密码修改失败');
} finally {
setChangingPassword(false);
}
};
const systemInfo = {
version: '1.0.0',
environment: import.meta.env.MODE,
apiBase: import.meta.env.VITE_API_BASE_URL || '/api/v1',
buildTime: new Date().toISOString().split('T')[0],
};
return (
<div className="p-6">
<Title level={4} className="mb-6"></Title>
<Row gutter={[16, 16]}>
{/* Admin Profile */}
<Col xs={24} lg={12}>
<Card
title={
<span>
<UserOutlined className="mr-2" />
</span>
}
>
<Descriptions column={1} size="small">
<Descriptions.Item label="用户名">
{admin?.username || '-'}
</Descriptions.Item>
<Descriptions.Item label="姓名">
{admin?.name || '-'}
</Descriptions.Item>
<Descriptions.Item label="角色">
{admin?.role || '-'}
</Descriptions.Item>
<Descriptions.Item label="权限">
<div className="flex flex-wrap gap-1">
{admin?.permissions?.map((p: string) => (
<span
key={p}
className="px-2 py-0.5 bg-blue-50 text-blue-600 text-xs rounded"
>
{p}
</span>
)) || '-'}
</div>
</Descriptions.Item>
</Descriptions>
</Card>
</Col>
{/* Security Settings */}
<Col xs={24} lg={12}>
<Card
title={
<span>
<SafetyOutlined className="mr-2" />
</span>
}
>
<div className="mb-4">
<Text type="secondary">
</Text>
</div>
<Button
type="primary"
icon={<LockOutlined />}
onClick={() => setPasswordModalOpen(true)}
>
</Button>
<Divider />
<div className="mb-4">
<Text type="secondary">
退
</Text>
</div>
<Button danger onClick={logout}>
退
</Button>
</Card>
</Col>
{/* System Info */}
<Col xs={24}>
<Card
title={
<span>
<InfoCircleOutlined className="mr-2" />
</span>
}
>
<Row gutter={[32, 16]}>
<Col xs={24} sm={12} md={6}>
<div className="text-gray-500 text-sm mb-1"></div>
<div className="font-medium">{systemInfo.version}</div>
</Col>
<Col xs={24} sm={12} md={6}>
<div className="text-gray-500 text-sm mb-1"></div>
<div className="font-medium">{systemInfo.environment}</div>
</Col>
<Col xs={24} sm={12} md={6}>
<div className="text-gray-500 text-sm mb-1">API </div>
<div className="font-medium text-xs break-all">
{systemInfo.apiBase}
</div>
</Col>
<Col xs={24} sm={12} md={6}>
<div className="text-gray-500 text-sm mb-1"></div>
<div className="font-medium">{systemInfo.buildTime}</div>
</Col>
</Row>
</Card>
</Col>
</Row>
{/* Change Password Modal */}
<Modal
title="修改密码"
open={passwordModalOpen}
onOk={handleChangePassword}
onCancel={() => {
setPasswordModalOpen(false);
changePasswordForm.resetFields();
}}
confirmLoading={changingPassword}
okText="确认修改"
cancelText="取消"
>
<Form
form={changePasswordForm}
layout="vertical"
className="mt-4"
>
<Form.Item
name="oldPassword"
label="当前密码"
rules={[{ required: true, message: '请输入当前密码' }]}
>
<Input.Password placeholder="请输入当前密码" />
</Form.Item>
<Form.Item
name="newPassword"
label="新密码"
rules={[
{ required: true, message: '请输入新密码' },
{ min: 6, message: '密码长度不能少于6位' },
]}
>
<Input.Password placeholder="请输入新密码" />
</Form.Item>
<Form.Item
name="confirmPassword"
label="确认新密码"
rules={[
{ required: true, message: '请确认新密码' },
({ getFieldValue }) => ({
validator(_, value) {
if (!value || getFieldValue('newPassword') === value) {
return Promise.resolve();
}
return Promise.reject(new Error('两次输入的密码不一致'));
},
}),
]}
>
<Input.Password placeholder="请再次输入新密码" />
</Form.Item>
</Form>
</Modal>
</div>
);
}

View File

@ -0,0 +1 @@
export * from './useUsers';

View File

@ -0,0 +1,40 @@
import { useQuery } from '@tanstack/react-query';
import { usersApi, UserQueryParams, UserDto, PaginatedUsers, UserStatistics } from '../infrastructure/users.api';
// User list query
export function useUsers(params: UserQueryParams) {
return useQuery<PaginatedUsers>({
queryKey: ['users', 'list', params],
queryFn: () => usersApi.listUsers(params),
});
}
// User statistics query
export function useUserStatistics() {
return useQuery<UserStatistics>({
queryKey: ['users', 'statistics'],
queryFn: () => usersApi.getStatistics(),
refetchInterval: 60000, // Refresh every minute
});
}
// User search query
export function useUserSearch(keyword: string, limit = 10, enabled = true) {
return useQuery<UserDto[]>({
queryKey: ['users', 'search', keyword, limit],
queryFn: () => usersApi.searchUsers(keyword, limit),
enabled: enabled && keyword.length >= 2,
});
}
// User detail query
export function useUserDetail(id: string, enabled = true) {
return useQuery<UserDto>({
queryKey: ['users', 'detail', id],
queryFn: () => usersApi.getUserDetail(id),
enabled: enabled && !!id,
});
}
// Re-export types
export type { UserDto, UserType, PaginatedUsers, UserStatistics, UserQueryParams } from '../infrastructure/users.api';

View File

@ -0,0 +1,3 @@
export * from './application';
export * from './infrastructure';
export { UsersPage } from './presentation/pages/UsersPage';

View File

@ -0,0 +1 @@
export * from './users.api';

View File

@ -0,0 +1,72 @@
import api from '../../../shared/utils/api';
// ==================== DTOs ====================
export type UserType = 'ANONYMOUS' | 'REGISTERED';
export interface UserDto {
id: string;
type: UserType;
phone: string | null;
nickname: string | null;
avatar: string | null;
fingerprint?: string | null;
createdAt: string;
updatedAt?: string;
lastActiveAt: string;
}
export interface PaginatedUsers {
items: UserDto[];
total: number;
page: number;
pageSize: number;
totalPages: number;
}
export interface UserStatistics {
total: number;
anonymous: number;
registered: number;
registrationRate: string;
}
export interface UserQueryParams {
type?: UserType;
phone?: string;
nickname?: string;
page?: number;
pageSize?: number;
sortBy?: 'createdAt' | 'lastActiveAt';
sortOrder?: 'ASC' | 'DESC';
}
// ==================== API ====================
export const usersApi = {
// List users with pagination
listUsers: async (params: UserQueryParams): Promise<PaginatedUsers> => {
const response = await api.get('/users/admin/list', { params });
return response.data.data;
},
// Get user statistics
getStatistics: async (): Promise<UserStatistics> => {
const response = await api.get('/users/admin/statistics');
return response.data.data;
},
// Search users
searchUsers: async (keyword: string, limit = 10): Promise<UserDto[]> => {
const response = await api.get('/users/admin/search', {
params: { keyword, limit },
});
return response.data.data;
},
// Get user detail
getUserDetail: async (id: string): Promise<UserDto> => {
const response = await api.get(`/users/admin/${id}`);
return response.data.data;
},
};

View File

@ -0,0 +1,328 @@
import { useState } from 'react';
import {
Card,
Table,
Tag,
Space,
Select,
Input,
Row,
Col,
Statistic,
Typography,
Drawer,
Descriptions,
Spin,
Avatar,
} from 'antd';
import {
UserOutlined,
TeamOutlined,
UserAddOutlined,
SearchOutlined,
} from '@ant-design/icons';
import type { ColumnsType } from 'antd/es/table';
import dayjs from 'dayjs';
import {
useUsers,
useUserStatistics,
useUserDetail,
type UserDto,
type UserType,
} from '../../application';
const { Title } = Typography;
const { Search } = Input;
const TYPE_COLORS: Record<UserType, string> = {
ANONYMOUS: 'default',
REGISTERED: 'green',
};
const TYPE_LABELS: Record<UserType, string> = {
ANONYMOUS: '匿名用户',
REGISTERED: '注册用户',
};
export function UsersPage() {
const [filters, setFilters] = useState<{
type?: UserType;
phone?: string;
nickname?: string;
page: number;
pageSize: number;
sortBy: 'createdAt' | 'lastActiveAt';
sortOrder: 'ASC' | 'DESC';
}>({
page: 1,
pageSize: 20,
sortBy: 'createdAt',
sortOrder: 'DESC',
});
const [searchKeyword, setSearchKeyword] = useState('');
const [detailDrawerOpen, setDetailDrawerOpen] = useState(false);
const [selectedUserId, setSelectedUserId] = useState<string | null>(null);
// Queries
const { data: usersData, isLoading: loadingUsers } = useUsers(filters);
const { data: stats, isLoading: loadingStats } = useUserStatistics();
const { data: userDetail, isLoading: loadingDetail } = useUserDetail(
selectedUserId || '',
!!selectedUserId && detailDrawerOpen
);
const showUserDetail = (user: UserDto) => {
setSelectedUserId(user.id);
setDetailDrawerOpen(true);
};
const handleSearch = (value: string) => {
setSearchKeyword(value);
if (value.length >= 2) {
// Search by phone or nickname
if (/^\d+$/.test(value)) {
setFilters((prev) => ({ ...prev, phone: value, nickname: undefined, page: 1 }));
} else {
setFilters((prev) => ({ ...prev, nickname: value, phone: undefined, page: 1 }));
}
} else if (value === '') {
setFilters((prev) => ({ ...prev, phone: undefined, nickname: undefined, page: 1 }));
}
};
const handleTableChange = (pagination: { current?: number; pageSize?: number }) => {
setFilters((prev) => ({
...prev,
page: pagination.current || 1,
pageSize: pagination.pageSize || 20,
}));
};
const columns: ColumnsType<UserDto> = [
{
title: '用户',
key: 'user',
render: (_, record) => (
<Space>
<Avatar
size="small"
src={record.avatar}
icon={<UserOutlined />}
/>
<span>{record.nickname || record.phone || record.id.slice(0, 8)}</span>
</Space>
),
},
{
title: '类型',
dataIndex: 'type',
key: 'type',
width: 100,
render: (type: UserType) => (
<Tag color={TYPE_COLORS[type]}>{TYPE_LABELS[type]}</Tag>
),
},
{
title: '手机号',
dataIndex: 'phone',
key: 'phone',
width: 140,
render: (phone) => phone || '-',
},
{
title: '注册时间',
dataIndex: 'createdAt',
key: 'createdAt',
width: 180,
render: (date) => dayjs(date).format('YYYY-MM-DD HH:mm'),
},
{
title: '最后活跃',
dataIndex: 'lastActiveAt',
key: 'lastActiveAt',
width: 180,
render: (date) => dayjs(date).format('YYYY-MM-DD HH:mm'),
},
{
title: '操作',
key: 'action',
width: 80,
render: (_, record) => (
<a onClick={() => showUserDetail(record)}></a>
),
},
];
return (
<div className="p-6">
<Title level={4} className="mb-6"></Title>
{/* Statistics Cards */}
<Row gutter={[16, 16]} className="mb-4">
<Col xs={24} sm={8}>
<Card>
<Spin spinning={loadingStats}>
<Statistic
title="总用户数"
value={stats?.total ?? 0}
prefix={<TeamOutlined />}
valueStyle={{ color: '#1890ff' }}
/>
</Spin>
</Card>
</Col>
<Col xs={24} sm={8}>
<Card>
<Spin spinning={loadingStats}>
<Statistic
title="注册用户"
value={stats?.registered ?? 0}
prefix={<UserAddOutlined />}
valueStyle={{ color: '#52c41a' }}
/>
<div className="mt-2 text-gray-500 text-sm">
: {stats?.registrationRate ?? '0'}%
</div>
</Spin>
</Card>
</Col>
<Col xs={24} sm={8}>
<Card>
<Spin spinning={loadingStats}>
<Statistic
title="匿名用户"
value={stats?.anonymous ?? 0}
prefix={<UserOutlined />}
valueStyle={{ color: '#8c8c8c' }}
/>
</Spin>
</Card>
</Col>
</Row>
{/* Filters */}
<Card className="mb-4">
<Space wrap>
<Search
placeholder="搜索手机号或昵称"
allowClear
value={searchKeyword}
onChange={(e) => setSearchKeyword(e.target.value)}
onSearch={handleSearch}
style={{ width: 220 }}
prefix={<SearchOutlined />}
/>
<Select
placeholder="用户类型"
allowClear
value={filters.type}
onChange={(value) => setFilters((prev) => ({ ...prev, type: value, page: 1 }))}
style={{ width: 120 }}
options={[
{ value: 'REGISTERED', label: '注册用户' },
{ value: 'ANONYMOUS', label: '匿名用户' },
]}
/>
<Select
value={filters.sortBy}
onChange={(value) => setFilters((prev) => ({ ...prev, sortBy: value }))}
style={{ width: 120 }}
options={[
{ value: 'createdAt', label: '注册时间' },
{ value: 'lastActiveAt', label: '活跃时间' },
]}
/>
<Select
value={filters.sortOrder}
onChange={(value) => setFilters((prev) => ({ ...prev, sortOrder: value }))}
style={{ width: 100 }}
options={[
{ value: 'DESC', label: '降序' },
{ value: 'ASC', label: '升序' },
]}
/>
</Space>
</Card>
{/* Users Table */}
<Card>
<Spin spinning={loadingUsers}>
<Table
columns={columns}
dataSource={usersData?.items || []}
rowKey="id"
pagination={{
current: usersData?.page || 1,
pageSize: usersData?.pageSize || 20,
total: usersData?.total || 0,
showSizeChanger: true,
showQuickJumper: true,
showTotal: (total) => `${total}`,
}}
onChange={handleTableChange}
/>
</Spin>
</Card>
{/* User Detail Drawer */}
<Drawer
title="用户详情"
placement="right"
width={480}
open={detailDrawerOpen}
onClose={() => {
setDetailDrawerOpen(false);
setSelectedUserId(null);
}}
>
<Spin spinning={loadingDetail}>
{userDetail && (
<div>
<div className="text-center mb-6">
<Avatar
size={80}
src={userDetail.avatar}
icon={<UserOutlined />}
/>
<div className="mt-3">
<Tag color={TYPE_COLORS[userDetail.type]}>
{TYPE_LABELS[userDetail.type]}
</Tag>
</div>
</div>
<Descriptions bordered column={1} size="small">
<Descriptions.Item label="用户ID">
<code className="text-xs">{userDetail.id}</code>
</Descriptions.Item>
<Descriptions.Item label="昵称">
{userDetail.nickname || '-'}
</Descriptions.Item>
<Descriptions.Item label="手机号">
{userDetail.phone || '-'}
</Descriptions.Item>
<Descriptions.Item label="设备指纹">
<code className="text-xs">
{userDetail.fingerprint?.slice(0, 16) || '-'}...
</code>
</Descriptions.Item>
<Descriptions.Item label="注册时间">
{dayjs(userDetail.createdAt).format('YYYY-MM-DD HH:mm:ss')}
</Descriptions.Item>
<Descriptions.Item label="更新时间">
{userDetail.updatedAt
? dayjs(userDetail.updatedAt).format('YYYY-MM-DD HH:mm:ss')
: '-'}
</Descriptions.Item>
<Descriptions.Item label="最后活跃">
{dayjs(userDetail.lastActiveAt).format('YYYY-MM-DD HH:mm:ss')}
</Descriptions.Item>
</Descriptions>
</div>
)}
</Spin>
</Drawer>
</div>
);
}

View File

@ -0,0 +1,192 @@
import {
Controller,
Get,
Query,
Param,
Headers,
UnauthorizedException,
} from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import * as jwt from 'jsonwebtoken';
import { UserService } from '../../application/services/user.service';
import { UserType } from '../../domain/entities/user.entity';
/**
* Admin User Management Controller
* Provides endpoints for admin dashboard user management
*/
@Controller('users/admin')
export class AdminUserController {
private readonly jwtSecret: string;
constructor(
private readonly userService: UserService,
private readonly configService: ConfigService,
) {
this.jwtSecret =
this.configService.get('JWT_SECRET') || 'iconsulting-secret-key';
}
/**
* Verify admin JWT token
*/
private verifyAdmin(authorization: string): { id: string; username: string } {
if (!authorization?.startsWith('Bearer ')) {
throw new UnauthorizedException('Missing authorization token');
}
const token = authorization.substring(7);
try {
const decoded = jwt.verify(token, this.jwtSecret) as {
sub: string;
username: string;
role?: string;
};
return { id: decoded.sub, username: decoded.username };
} catch {
throw new UnauthorizedException('Invalid or expired token');
}
}
/**
* GET /users/admin/list
* List users with pagination and filters
*/
@Get('list')
async listUsers(
@Headers('authorization') auth: string,
@Query('type') type?: UserType,
@Query('phone') phone?: string,
@Query('nickname') nickname?: string,
@Query('page') page = '1',
@Query('pageSize') pageSize = '20',
@Query('sortBy') sortBy?: 'createdAt' | 'lastActiveAt',
@Query('sortOrder') sortOrder?: 'ASC' | 'DESC',
) {
this.verifyAdmin(auth);
const result = await this.userService.findAll({
type,
phone,
nickname,
page: parseInt(page, 10),
pageSize: parseInt(pageSize, 10),
sortBy,
sortOrder,
});
return {
success: true,
data: {
items: result.items.map((user) => ({
id: user.id,
type: user.type,
phone: user.phone,
nickname: user.nickname,
avatar: user.avatar,
createdAt: user.createdAt,
lastActiveAt: user.lastActiveAt,
})),
total: result.total,
page: result.page,
pageSize: result.pageSize,
totalPages: result.totalPages,
},
};
}
/**
* GET /users/admin/statistics
* Get user statistics
*/
@Get('statistics')
async getUserStatistics(@Headers('authorization') auth: string) {
this.verifyAdmin(auth);
const countByType = await this.userService.countByType();
const total =
countByType[UserType.ANONYMOUS] + countByType[UserType.REGISTERED];
return {
success: true,
data: {
total,
anonymous: countByType[UserType.ANONYMOUS],
registered: countByType[UserType.REGISTERED],
registrationRate:
total > 0
? ((countByType[UserType.REGISTERED] / total) * 100).toFixed(1)
: '0',
},
};
}
/**
* GET /users/admin/search
* Search users by keyword
*/
@Get('search')
async searchUsers(
@Headers('authorization') auth: string,
@Query('keyword') keyword: string,
@Query('limit') limit = '10',
) {
this.verifyAdmin(auth);
if (!keyword || keyword.length < 2) {
return {
success: true,
data: [],
};
}
const users = await this.userService.searchUsers(
keyword,
parseInt(limit, 10),
);
return {
success: true,
data: users.map((user) => ({
id: user.id,
type: user.type,
phone: user.phone,
nickname: user.nickname,
avatar: user.avatar,
createdAt: user.createdAt,
lastActiveAt: user.lastActiveAt,
})),
};
}
/**
* GET /users/admin/:id
* Get user detail by ID
*/
@Get(':id')
async getUserDetail(
@Headers('authorization') auth: string,
@Param('id') id: string,
) {
this.verifyAdmin(auth);
const user = await this.userService.findById(id);
return {
success: true,
data: {
id: user.id,
type: user.type,
phone: user.phone,
nickname: user.nickname,
avatar: user.avatar,
fingerprint: user.fingerprint,
createdAt: user.createdAt,
updatedAt: user.updatedAt,
lastActiveAt: user.lastActiveAt,
},
};
}
}

View File

@ -1,2 +1,3 @@
export * from './user.controller';
export * from './auth.controller';
export * from './admin-user.controller';

View File

@ -1,8 +1,12 @@
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { IUserRepository } from '../../../domain/repositories/user.repository.interface';
import { UserEntity } from '../../../domain/entities/user.entity';
import { Repository, Like, FindOptionsWhere } from 'typeorm';
import {
IUserRepository,
UserQueryOptions,
PaginatedUsers,
} from '../../../domain/repositories/user.repository.interface';
import { UserEntity, UserType } from '../../../domain/entities/user.entity';
import { UserORM } from '../../../infrastructure/database/postgres/entities/user.orm';
@Injectable()
@ -37,6 +41,73 @@ export class UserPostgresRepository implements IUserRepository {
await this.repo.update(userId, { lastActiveAt: new Date() });
}
async findAll(options: UserQueryOptions): Promise<PaginatedUsers> {
const page = options.page || 1;
const pageSize = options.pageSize || 20;
const skip = (page - 1) * pageSize;
const where: FindOptionsWhere<UserORM> = {};
if (options.type) {
where.type = options.type;
}
if (options.phone) {
where.phone = Like(`%${options.phone}%`);
}
if (options.nickname) {
where.nickname = Like(`%${options.nickname}%`);
}
const [items, total] = await this.repo.findAndCount({
where,
order: {
[options.sortBy || 'createdAt']: options.sortOrder || 'DESC',
},
skip,
take: pageSize,
});
return {
items: items.map((orm) => this.toEntity(orm)),
total,
page,
pageSize,
totalPages: Math.ceil(total / pageSize),
};
}
async countByType(): Promise<Record<UserType, number>> {
const result = await this.repo
.createQueryBuilder('user')
.select('user.type', 'type')
.addSelect('COUNT(*)', 'count')
.groupBy('user.type')
.getRawMany();
const counts: Record<UserType, number> = {
[UserType.ANONYMOUS]: 0,
[UserType.REGISTERED]: 0,
};
for (const row of result) {
counts[row.type as UserType] = parseInt(row.count, 10);
}
return counts;
}
async search(keyword: string, limit = 10): Promise<UserEntity[]> {
const items = await this.repo.find({
where: [
{ phone: Like(`%${keyword}%`) },
{ nickname: Like(`%${keyword}%`) },
],
take: limit,
order: { lastActiveAt: 'DESC' },
});
return items.map((orm) => this.toEntity(orm));
}
private toORM(entity: UserEntity): UserORM {
const orm = new UserORM();
orm.id = entity.id;

View File

@ -1,8 +1,10 @@
import { Injectable, Inject, NotFoundException } from '@nestjs/common';
import { UserEntity } from '../../domain/entities/user.entity';
import { UserEntity, UserType } from '../../domain/entities/user.entity';
import {
IUserRepository,
USER_REPOSITORY,
UserQueryOptions,
PaginatedUsers,
} from '../../domain/repositories/user.repository.interface';
@Injectable()
@ -66,4 +68,17 @@ export class UserService {
user.updateProfile(data);
return this.userRepo.save(user);
}
// Admin methods
async findAll(options: UserQueryOptions): Promise<PaginatedUsers> {
return this.userRepo.findAll(options);
}
async countByType(): Promise<Record<UserType, number>> {
return this.userRepo.countByType();
}
async searchUsers(keyword: string, limit?: number): Promise<UserEntity[]> {
return this.userRepo.search(keyword, limit);
}
}

View File

@ -1,4 +1,28 @@
import { UserEntity } from '../entities/user.entity';
import { UserEntity, UserType } from '../entities/user.entity';
/**
* User query options for admin
*/
export interface UserQueryOptions {
type?: UserType;
phone?: string;
nickname?: string;
page?: number;
pageSize?: number;
sortBy?: 'createdAt' | 'lastActiveAt';
sortOrder?: 'ASC' | 'DESC';
}
/**
* Paginated result
*/
export interface PaginatedUsers {
items: UserEntity[];
total: number;
page: number;
pageSize: number;
totalPages: number;
}
/**
* User Repository Interface
@ -29,6 +53,21 @@ export interface IUserRepository {
* Update last active timestamp
*/
updateLastActive(userId: string): Promise<void>;
/**
* Find all users with pagination and filters (admin)
*/
findAll(options: UserQueryOptions): Promise<PaginatedUsers>;
/**
* Count users by type (admin)
*/
countByType(): Promise<Record<UserType, number>>;
/**
* Search users by keyword (phone or nickname)
*/
search(keyword: string, limit?: number): Promise<UserEntity[]>;
}
/**

View File

@ -5,10 +5,11 @@ import { UserPostgresRepository } from '../adapters/outbound/persistence/user-po
import { USER_REPOSITORY } from '../domain/repositories/user.repository.interface';
import { UserService } from '../application/services/user.service';
import { UserController } from '../adapters/inbound/user.controller';
import { AdminUserController } from '../adapters/inbound/admin-user.controller';
@Module({
imports: [TypeOrmModule.forFeature([UserORM])],
controllers: [UserController],
controllers: [UserController, AdminUserController],
providers: [
UserService,
{