+
用户管理
+
+ {/* Statistics Cards */}
+
+
+
+
+ }
+ valueStyle={{ color: '#1890ff' }}
+ />
+
+
+
+
+
+
+ }
+ valueStyle={{ color: '#52c41a' }}
+ />
+
+ 注册率: {stats?.registrationRate ?? '0'}%
+
+
+
+
+
+
+
+ }
+ valueStyle={{ color: '#8c8c8c' }}
+ />
+
+
+
+
+
+ {/* Filters */}
+
+
+ setSearchKeyword(e.target.value)}
+ onSearch={handleSearch}
+ style={{ width: 220 }}
+ prefix={}
+ />
+
+
+
+ {/* Users Table */}
+
+
+ `共 ${total} 条`,
+ }}
+ onChange={handleTableChange}
+ />
+
+
+
+ {/* User Detail Drawer */}
+ {
+ setDetailDrawerOpen(false);
+ setSelectedUserId(null);
+ }}
+ >
+
+ {userDetail && (
+
+
+
}
+ />
+
+
+ {TYPE_LABELS[userDetail.type]}
+
+
+
+
+
+
+ {userDetail.id}
+
+
+ {userDetail.nickname || '-'}
+
+
+ {userDetail.phone || '-'}
+
+
+
+ {userDetail.fingerprint?.slice(0, 16) || '-'}...
+
+
+
+ {dayjs(userDetail.createdAt).format('YYYY-MM-DD HH:mm:ss')}
+
+
+ {userDetail.updatedAt
+ ? dayjs(userDetail.updatedAt).format('YYYY-MM-DD HH:mm:ss')
+ : '-'}
+
+
+ {dayjs(userDetail.lastActiveAt).format('YYYY-MM-DD HH:mm:ss')}
+
+
+
+ )}
+
+
+
+ );
+}
diff --git a/packages/services/user-service/src/adapters/inbound/admin-user.controller.ts b/packages/services/user-service/src/adapters/inbound/admin-user.controller.ts
new file mode 100644
index 0000000..7675057
--- /dev/null
+++ b/packages/services/user-service/src/adapters/inbound/admin-user.controller.ts
@@ -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,
+ },
+ };
+ }
+}
diff --git a/packages/services/user-service/src/adapters/inbound/index.ts b/packages/services/user-service/src/adapters/inbound/index.ts
index 99ddf78..a36ac12 100644
--- a/packages/services/user-service/src/adapters/inbound/index.ts
+++ b/packages/services/user-service/src/adapters/inbound/index.ts
@@ -1,2 +1,3 @@
export * from './user.controller';
export * from './auth.controller';
+export * from './admin-user.controller';
diff --git a/packages/services/user-service/src/adapters/outbound/persistence/user-postgres.repository.ts b/packages/services/user-service/src/adapters/outbound/persistence/user-postgres.repository.ts
index 8124d36..836a4cc 100644
--- a/packages/services/user-service/src/adapters/outbound/persistence/user-postgres.repository.ts
+++ b/packages/services/user-service/src/adapters/outbound/persistence/user-postgres.repository.ts
@@ -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 {
+ const page = options.page || 1;
+ const pageSize = options.pageSize || 20;
+ const skip = (page - 1) * pageSize;
+
+ const where: FindOptionsWhere = {};
+ 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> {
+ const result = await this.repo
+ .createQueryBuilder('user')
+ .select('user.type', 'type')
+ .addSelect('COUNT(*)', 'count')
+ .groupBy('user.type')
+ .getRawMany();
+
+ const counts: Record = {
+ [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 {
+ 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;
diff --git a/packages/services/user-service/src/application/services/user.service.ts b/packages/services/user-service/src/application/services/user.service.ts
index a5763c6..d2b1b3e 100644
--- a/packages/services/user-service/src/application/services/user.service.ts
+++ b/packages/services/user-service/src/application/services/user.service.ts
@@ -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 {
+ return this.userRepo.findAll(options);
+ }
+
+ async countByType(): Promise> {
+ return this.userRepo.countByType();
+ }
+
+ async searchUsers(keyword: string, limit?: number): Promise {
+ return this.userRepo.search(keyword, limit);
+ }
}
diff --git a/packages/services/user-service/src/domain/repositories/user.repository.interface.ts b/packages/services/user-service/src/domain/repositories/user.repository.interface.ts
index 890340d..3c60af3 100644
--- a/packages/services/user-service/src/domain/repositories/user.repository.interface.ts
+++ b/packages/services/user-service/src/domain/repositories/user.repository.interface.ts
@@ -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;
+
+ /**
+ * Find all users with pagination and filters (admin)
+ */
+ findAll(options: UserQueryOptions): Promise;
+
+ /**
+ * Count users by type (admin)
+ */
+ countByType(): Promise>;
+
+ /**
+ * Search users by keyword (phone or nickname)
+ */
+ search(keyword: string, limit?: number): Promise;
}
/**
diff --git a/packages/services/user-service/src/user/user.module.ts b/packages/services/user-service/src/user/user.module.ts
index c3546bb..f6b6051 100644
--- a/packages/services/user-service/src/user/user.module.ts
+++ b/packages/services/user-service/src/user/user.module.ts
@@ -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,
{