From ca619bff0b8b47fb02dafca3c25b5cfbc971936b Mon Sep 17 00:00:00 2001 From: hailin Date: Thu, 18 Dec 2025 02:29:11 -0800 Subject: [PATCH] =?UTF-8?q?feat(admin):=20=E5=AE=9E=E7=8E=B0=E7=94=A8?= =?UTF-8?q?=E6=88=B7=E7=AE=A1=E7=90=86=E5=8A=9F=E8=83=BD=E5=AE=8C=E6=95=B4?= =?UTF-8?q?=E5=89=8D=E5=90=8E=E7=AB=AF=E6=9E=B6=E6=9E=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 概述 为 admin-web 用户管理页面实现完整的前后端架构,采用事件驱动 CQRS 模式, 通过 Kafka 事件同步用户数据到本地物化视图,避免跨服务 HTTP 调用。 ## admin-service 后端变更 ### 数据库 Schema - UserQueryView: 用户查询视图表 (通过 Kafka 事件同步) - EventConsumerOffset: 事件消费位置追踪 - ProcessedEvent: 已处理事件记录 (幂等性) ### 新增组件 - IUserQueryRepository: 用户查询仓储接口 - UserQueryRepositoryImpl: 用户查询仓储实现 - UserEventConsumerService: Kafka 事件消费者 - UserController: 用户管理 API 控制器 ### API 端点 - GET /admin/users: 用户列表 (分页/筛选/排序) - GET /admin/users/:id: 用户详情 - GET /admin/users/stats/summary: 用户统计 ## identity-service 变更 - 新增 UserProfileUpdatedEvent 事件 - updateProfile 方法现在会发布事件 ## admin-web 前端变更 - userService: 用户 API 服务封装 - useUsers/useUserDetail: React Query hooks - 用户管理页面接入真实 API - 添加加载骨架屏/错误重试/空数据提示 ## 架构特点 - CQRS: 读从本地视图,写触发事件 - 事件驱动: Kafka 事件同步,微服务解耦 - Outbox 模式: 可靠事件发布 - 幂等性: ProcessedEvent 防重复处理 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- .../admin-service/prisma/schema.prisma | 80 ++++ .../src/api/controllers/user.controller.ts | 193 ++++++++ .../src/api/dto/request/user-query.dto.ts | 77 +++ .../src/api/dto/response/user.dto.ts | 45 ++ .../services/admin-service/src/app.module.ts | 12 + .../repositories/user-query.repository.ts | 155 ++++++ .../src/infrastructure/kafka/index.ts | 2 + .../src/infrastructure/kafka/kafka.module.ts | 20 + .../kafka/user-event-consumer.service.ts | 407 ++++++++++++++++ .../user-query.repository.impl.ts | 317 ++++++++++++ .../services/user-application.service.ts | 12 +- .../src/domain/events/index.ts | 22 + .../src/app/(dashboard)/users/page.tsx | 450 +++++++++--------- frontend/admin-web/src/hooks/index.ts | 1 + frontend/admin-web/src/hooks/useUsers.ts | 61 +++ .../src/infrastructure/api/endpoints.ts | 15 +- .../admin-web/src/services/userService.ts | 111 +++++ 17 files changed, 1751 insertions(+), 229 deletions(-) create mode 100644 backend/services/admin-service/src/api/controllers/user.controller.ts create mode 100644 backend/services/admin-service/src/api/dto/request/user-query.dto.ts create mode 100644 backend/services/admin-service/src/api/dto/response/user.dto.ts create mode 100644 backend/services/admin-service/src/domain/repositories/user-query.repository.ts create mode 100644 backend/services/admin-service/src/infrastructure/kafka/index.ts create mode 100644 backend/services/admin-service/src/infrastructure/kafka/kafka.module.ts create mode 100644 backend/services/admin-service/src/infrastructure/kafka/user-event-consumer.service.ts create mode 100644 backend/services/admin-service/src/infrastructure/persistence/repositories/user-query.repository.impl.ts create mode 100644 frontend/admin-web/src/hooks/useUsers.ts create mode 100644 frontend/admin-web/src/services/userService.ts diff --git a/backend/services/admin-service/prisma/schema.prisma b/backend/services/admin-service/prisma/schema.prisma index fd593263..7a540b8d 100644 --- a/backend/services/admin-service/prisma/schema.prisma +++ b/backend/services/admin-service/prisma/schema.prisma @@ -110,3 +110,83 @@ enum TargetType { NEW_USER // 新用户 VIP // VIP用户 } + +// ============================================================================= +// User Query View (用户查询视图 - 通过 Kafka 事件同步) +// ============================================================================= + +/// 用户查询视图 - 本地物化视图,通过消费 Kafka 事件同步维护 +/// 用于 admin-web 用户管理页面的查询,避免跨服务 HTTP 调用 +model UserQueryView { + userId BigInt @id @map("user_id") + accountSequence String @unique @map("account_sequence") @db.VarChar(12) + + // 基本信息 (来自 identity-service 事件) + nickname String? @db.VarChar(100) + avatarUrl String? @map("avatar_url") @db.Text + phoneNumberMasked String? @map("phone_number_masked") @db.VarChar(20) // 脱敏: 138****8888 + + // 推荐关系 + inviterSequence String? @map("inviter_sequence") @db.VarChar(12) + + // KYC 状态 + kycStatus String @default("NOT_VERIFIED") @map("kyc_status") @db.VarChar(20) + + // 认种统计 (来自 planting-service 事件) + personalAdoptionCount Int @default(0) @map("personal_adoption_count") + teamAddressCount Int @default(0) @map("team_address_count") + teamAdoptionCount Int @default(0) @map("team_adoption_count") + + // 授权统计 (来自 authorization-service 事件) + provinceAdoptionCount Int @default(0) @map("province_adoption_count") + cityAdoptionCount Int @default(0) @map("city_adoption_count") + + // 排名 + leaderboardRank Int? @map("leaderboard_rank") + + // 状态 + status String @default("ACTIVE") @db.VarChar(20) + isOnline Boolean @default(false) @map("is_online") + + // 时间戳 + registeredAt DateTime @map("registered_at") + lastActiveAt DateTime? @map("last_active_at") + syncedAt DateTime @default(now()) @map("synced_at") + + @@index([accountSequence]) + @@index([nickname]) + @@index([status]) + @@index([registeredAt]) + @@index([personalAdoptionCount]) + @@index([inviterSequence]) + @@map("user_query_view") +} + +// ============================================================================= +// Kafka Event Tracking (事件消费追踪) +// ============================================================================= + +/// 事件消费位置追踪 - 用于幂等性和断点续传 +model EventConsumerOffset { + id BigInt @id @default(autoincrement()) + consumerGroup String @map("consumer_group") @db.VarChar(100) + topic String @db.VarChar(100) + partition Int + offset BigInt + updatedAt DateTime @default(now()) @map("updated_at") + + @@unique([consumerGroup, topic, partition]) + @@map("event_consumer_offsets") +} + +/// 已处理事件记录 - 用于幂等性检查 +model ProcessedEvent { + id BigInt @id @default(autoincrement()) + eventId String @unique @map("event_id") @db.VarChar(100) + eventType String @map("event_type") @db.VarChar(50) + processedAt DateTime @default(now()) @map("processed_at") + + @@index([eventType]) + @@index([processedAt]) + @@map("processed_events") +} diff --git a/backend/services/admin-service/src/api/controllers/user.controller.ts b/backend/services/admin-service/src/api/controllers/user.controller.ts new file mode 100644 index 00000000..a5c9ab12 --- /dev/null +++ b/backend/services/admin-service/src/api/controllers/user.controller.ts @@ -0,0 +1,193 @@ +import { + Controller, + Get, + Param, + Query, + HttpCode, + HttpStatus, + NotFoundException, + Inject, +} from '@nestjs/common'; +import { ListUsersDto } from '../dto/request/user-query.dto'; +import { UserListResponseDto, UserListItemDto, UserDetailDto } from '../dto/response/user.dto'; +import { + IUserQueryRepository, + USER_QUERY_REPOSITORY, + UserQueryItem, + UserQueryFilters, + UserQuerySort, +} from '../../domain/repositories/user-query.repository'; + +/** + * 用户管理控制器 + * + * 为 admin-web 提供用户查询接口 + */ +@Controller('admin/users') +export class UserController { + constructor( + @Inject(USER_QUERY_REPOSITORY) + private readonly userQueryRepository: IUserQueryRepository, + ) {} + + /** + * 获取用户列表 + * GET /admin/users + */ + @Get() + @HttpCode(HttpStatus.OK) + async listUsers(@Query() query: ListUsersDto): Promise { + // 构建筛选条件 + const filters: UserQueryFilters = { + keyword: query.keyword, + status: query.status, + kycStatus: query.kycStatus, + hasInviter: query.hasInviter, + minAdoptions: query.minAdoptions, + maxAdoptions: query.maxAdoptions, + registeredAfter: query.registeredAfter ? new Date(query.registeredAfter) : undefined, + registeredBefore: query.registeredBefore ? new Date(query.registeredBefore) : undefined, + }; + + // 构建排序条件 + const sort: UserQuerySort | undefined = query.sortBy ? { + field: query.sortBy as UserQuerySort['field'], + order: query.sortOrder || 'desc', + } : undefined; + + // 查询数据 + const result = await this.userQueryRepository.findMany( + filters, + { page: query.page || 1, pageSize: query.pageSize || 10 }, + sort, + ); + + // 获取所有用户的团队总认种数用于计算百分比 + const totalTeamAdoptions = result.items.reduce((sum, item) => sum + item.teamAdoptionCount, 0); + + return { + items: result.items.map((item) => this.mapToListItem(item, totalTeamAdoptions)), + total: result.total, + page: result.page, + pageSize: result.pageSize, + totalPages: result.totalPages, + }; + } + + /** + * 获取用户详情 + * GET /admin/users/:id + */ + @Get(':id') + @HttpCode(HttpStatus.OK) + async getUserDetail(@Param('id') id: string): Promise { + let user: UserQueryItem | null = null; + + // 尝试作为账号序列号查询 + if (id.startsWith('D')) { + user = await this.userQueryRepository.findByAccountSequence(id); + } + + // 如果不是序列号或未找到,尝试作为 userId 查询 + if (!user) { + try { + const userId = BigInt(id); + user = await this.userQueryRepository.findById(userId); + } catch { + // 无法转换为 BigInt,忽略 + } + } + + if (!user) { + throw new NotFoundException(`用户 ${id} 不存在`); + } + + return this.mapToDetail(user); + } + + /** + * 获取用户统计信息 + * GET /admin/users/stats/summary + */ + @Get('stats/summary') + @HttpCode(HttpStatus.OK) + async getUserStats(): Promise<{ + totalUsers: number; + activeUsers: number; + frozenUsers: number; + verifiedUsers: number; + }> { + const [total, active, frozen, verified] = await Promise.all([ + this.userQueryRepository.count(), + this.userQueryRepository.count({ status: 'ACTIVE' }), + this.userQueryRepository.count({ status: 'FROZEN' }), + this.userQueryRepository.count({ kycStatus: 'VERIFIED' }), + ]); + + return { + totalUsers: total, + activeUsers: active, + frozenUsers: frozen, + verifiedUsers: verified, + }; + } + + // ==================== Private Methods ==================== + + private mapToListItem(item: UserQueryItem, totalTeamAdoptions: number): UserListItemDto { + // 计算省市认种百分比 + const provincePercentage = totalTeamAdoptions > 0 + ? Math.round((item.provinceAdoptionCount / totalTeamAdoptions) * 100) + : 0; + const cityPercentage = totalTeamAdoptions > 0 + ? Math.round((item.cityAdoptionCount / totalTeamAdoptions) * 100) + : 0; + + return { + accountId: item.userId.toString(), + accountSequence: item.accountSequence, + avatar: item.avatarUrl, + nickname: item.nickname, + personalAdoptions: item.personalAdoptionCount, + teamAddresses: item.teamAddressCount, + teamAdoptions: item.teamAdoptionCount, + provincialAdoptions: { + count: item.provinceAdoptionCount, + percentage: provincePercentage, + }, + cityAdoptions: { + count: item.cityAdoptionCount, + percentage: cityPercentage, + }, + referrerId: item.inviterSequence, + ranking: item.leaderboardRank, + status: this.mapStatus(item.status), + isOnline: item.isOnline, + }; + } + + private mapToDetail(item: UserQueryItem): UserDetailDto { + const listItem = this.mapToListItem(item, 0); + + return { + ...listItem, + phoneNumberMasked: item.phoneNumberMasked, + kycStatus: item.kycStatus, + registeredAt: item.registeredAt.toISOString(), + lastActiveAt: item.lastActiveAt?.toISOString() || null, + }; + } + + private mapStatus(status: string): 'active' | 'frozen' | 'deactivated' { + switch (status.toUpperCase()) { + case 'ACTIVE': + return 'active'; + case 'FROZEN': + return 'frozen'; + case 'DEACTIVATED': + return 'deactivated'; + default: + return 'active'; + } + } +} diff --git a/backend/services/admin-service/src/api/dto/request/user-query.dto.ts b/backend/services/admin-service/src/api/dto/request/user-query.dto.ts new file mode 100644 index 00000000..c9fbb762 --- /dev/null +++ b/backend/services/admin-service/src/api/dto/request/user-query.dto.ts @@ -0,0 +1,77 @@ +import { IsOptional, IsString, IsInt, IsBoolean, Min, Max, IsDateString, IsIn } from 'class-validator'; +import { Transform, Type } from 'class-transformer'; + +/** + * 用户列表查询 DTO + */ +export class ListUsersDto { + @IsOptional() + @IsString() + keyword?: string; + + @IsOptional() + @IsString() + @IsIn(['ACTIVE', 'FROZEN', 'DEACTIVATED']) + status?: string; + + @IsOptional() + @IsString() + @IsIn(['NOT_VERIFIED', 'PENDING', 'VERIFIED', 'REJECTED']) + kycStatus?: string; + + @IsOptional() + @Transform(({ value }) => value === 'true' || value === true) + @IsBoolean() + hasInviter?: boolean; + + @IsOptional() + @Type(() => Number) + @IsInt() + @Min(0) + minAdoptions?: number; + + @IsOptional() + @Type(() => Number) + @IsInt() + @Min(0) + maxAdoptions?: number; + + @IsOptional() + @IsDateString() + registeredAfter?: string; + + @IsOptional() + @IsDateString() + registeredBefore?: string; + + @IsOptional() + @Type(() => Number) + @IsInt() + @Min(1) + page?: number = 1; + + @IsOptional() + @Type(() => Number) + @IsInt() + @Min(1) + @Max(100) + pageSize?: number = 10; + + @IsOptional() + @IsString() + @IsIn(['registeredAt', 'personalAdoptionCount', 'teamAdoptionCount', 'leaderboardRank']) + sortBy?: string = 'registeredAt'; + + @IsOptional() + @IsString() + @IsIn(['asc', 'desc']) + sortOrder?: 'asc' | 'desc' = 'desc'; +} + +/** + * 获取用户详情 DTO + */ +export class GetUserDetailDto { + @IsString() + id!: string; // 可以是 userId 或 accountSequence +} diff --git a/backend/services/admin-service/src/api/dto/response/user.dto.ts b/backend/services/admin-service/src/api/dto/response/user.dto.ts new file mode 100644 index 00000000..c07874ed --- /dev/null +++ b/backend/services/admin-service/src/api/dto/response/user.dto.ts @@ -0,0 +1,45 @@ +/** + * 用户列表项响应 DTO + */ +export class UserListItemDto { + accountId!: string; + accountSequence!: string; + avatar!: string | null; + nickname!: string | null; + personalAdoptions!: number; + teamAddresses!: number; + teamAdoptions!: number; + provincialAdoptions!: { + count: number; + percentage: number; + }; + cityAdoptions!: { + count: number; + percentage: number; + }; + referrerId!: string | null; + ranking!: number | null; + status!: 'active' | 'frozen' | 'deactivated'; + isOnline!: boolean; +} + +/** + * 用户列表响应 DTO + */ +export class UserListResponseDto { + items!: UserListItemDto[]; + total!: number; + page!: number; + pageSize!: number; + totalPages!: number; +} + +/** + * 用户详情响应 DTO + */ +export class UserDetailDto extends UserListItemDto { + phoneNumberMasked!: string | null; + kycStatus!: string; + registeredAt!: string; + lastActiveAt!: string | null; +} diff --git a/backend/services/admin-service/src/app.module.ts b/backend/services/admin-service/src/app.module.ts index f5b194f9..b9c2af11 100644 --- a/backend/services/admin-service/src/app.module.ts +++ b/backend/services/admin-service/src/app.module.ts @@ -26,6 +26,11 @@ import { NotificationMapper } from './infrastructure/persistence/mappers/notific import { NotificationRepositoryImpl } from './infrastructure/persistence/repositories/notification.repository.impl'; import { NOTIFICATION_REPOSITORY } from './domain/repositories/notification.repository'; import { AdminNotificationController, MobileNotificationController } from './api/controllers/notification.controller'; +// User Management imports +import { UserQueryRepositoryImpl } from './infrastructure/persistence/repositories/user-query.repository.impl'; +import { USER_QUERY_REPOSITORY } from './domain/repositories/user-query.repository'; +import { UserController } from './api/controllers/user.controller'; +import { UserEventConsumerService } from './infrastructure/kafka/user-event-consumer.service'; @Module({ imports: [ @@ -46,6 +51,7 @@ import { AdminNotificationController, MobileNotificationController } from './api DownloadController, AdminNotificationController, MobileNotificationController, + UserController, ], providers: [ PrismaService, @@ -72,6 +78,12 @@ import { AdminNotificationController, MobileNotificationController } from './api provide: NOTIFICATION_REPOSITORY, useClass: NotificationRepositoryImpl, }, + // User Management + { + provide: USER_QUERY_REPOSITORY, + useClass: UserQueryRepositoryImpl, + }, + UserEventConsumerService, ], }) export class AppModule {} diff --git a/backend/services/admin-service/src/domain/repositories/user-query.repository.ts b/backend/services/admin-service/src/domain/repositories/user-query.repository.ts new file mode 100644 index 00000000..daf1ae90 --- /dev/null +++ b/backend/services/admin-service/src/domain/repositories/user-query.repository.ts @@ -0,0 +1,155 @@ +/** + * 用户查询仓储接口 + * 用于 admin-web 用户管理页面的查询操作 + */ + +export interface UserQueryItem { + userId: bigint; + accountSequence: string; + nickname: string | null; + avatarUrl: string | null; + phoneNumberMasked: string | null; + inviterSequence: string | null; + kycStatus: string; + personalAdoptionCount: number; + teamAddressCount: number; + teamAdoptionCount: number; + provinceAdoptionCount: number; + cityAdoptionCount: number; + leaderboardRank: number | null; + status: string; + isOnline: boolean; + registeredAt: Date; + lastActiveAt: Date | null; +} + +export interface UserQueryFilters { + keyword?: string; // 搜索关键词 (账号序列号/昵称) + status?: string; // 状态筛选 + kycStatus?: string; // KYC状态筛选 + hasInviter?: boolean; // 是否有推荐人 + minAdoptions?: number; // 最小认种数 + maxAdoptions?: number; // 最大认种数 + registeredAfter?: Date; // 注册时间开始 + registeredBefore?: Date; // 注册时间结束 +} + +export interface UserQuerySort { + field: 'registeredAt' | 'personalAdoptionCount' | 'teamAdoptionCount' | 'leaderboardRank'; + order: 'asc' | 'desc'; +} + +export interface UserQueryPagination { + page: number; + pageSize: number; +} + +export interface UserQueryResult { + items: UserQueryItem[]; + total: number; + page: number; + pageSize: number; + totalPages: number; +} + +export const USER_QUERY_REPOSITORY = Symbol('USER_QUERY_REPOSITORY'); + +export interface IUserQueryRepository { + /** + * 查询用户列表 + */ + findMany( + filters: UserQueryFilters, + pagination: UserQueryPagination, + sort?: UserQuerySort, + ): Promise; + + /** + * 根据用户ID查询用户详情 + */ + findById(userId: bigint): Promise; + + /** + * 根据账号序列号查询用户详情 + */ + findByAccountSequence(accountSequence: string): Promise; + + /** + * 创建或更新用户视图记录 (用于 Kafka 事件同步) + */ + upsert(data: { + userId: bigint; + accountSequence: string; + nickname?: string | null; + avatarUrl?: string | null; + phoneNumberMasked?: string | null; + inviterSequence?: string | null; + kycStatus?: string; + status?: string; + registeredAt: Date; + }): Promise; + + /** + * 更新用户资料 + */ + updateProfile( + userId: bigint, + data: { + nickname?: string | null; + avatarUrl?: string | null; + }, + ): Promise; + + /** + * 更新认种统计 + */ + updateAdoptionStats( + userId: bigint, + data: { + personalAdoptionCount?: number; + teamAddressCount?: number; + teamAdoptionCount?: number; + }, + ): Promise; + + /** + * 更新授权统计 + */ + updateAuthorizationStats( + userId: bigint, + data: { + provinceAdoptionCount?: number; + cityAdoptionCount?: number; + }, + ): Promise; + + /** + * 更新用户状态 + */ + updateStatus(userId: bigint, status: string): Promise; + + /** + * 更新 KYC 状态 + */ + updateKycStatus(userId: bigint, kycStatus: string): Promise; + + /** + * 更新在线状态 + */ + updateOnlineStatus(userId: bigint, isOnline: boolean): Promise; + + /** + * 批量更新在线状态 + */ + batchUpdateOnlineStatus(userIds: bigint[], isOnline: boolean): Promise; + + /** + * 获取用户总数 + */ + count(filters?: UserQueryFilters): Promise; + + /** + * 检查用户是否存在 + */ + exists(userId: bigint): Promise; +} diff --git a/backend/services/admin-service/src/infrastructure/kafka/index.ts b/backend/services/admin-service/src/infrastructure/kafka/index.ts new file mode 100644 index 00000000..05175f74 --- /dev/null +++ b/backend/services/admin-service/src/infrastructure/kafka/index.ts @@ -0,0 +1,2 @@ +export * from './kafka.module'; +export * from './user-event-consumer.service'; diff --git a/backend/services/admin-service/src/infrastructure/kafka/kafka.module.ts b/backend/services/admin-service/src/infrastructure/kafka/kafka.module.ts new file mode 100644 index 00000000..37175dae --- /dev/null +++ b/backend/services/admin-service/src/infrastructure/kafka/kafka.module.ts @@ -0,0 +1,20 @@ +import { Module } from '@nestjs/common'; +import { ConfigModule } from '@nestjs/config'; +import { PrismaService } from '../persistence/prisma/prisma.service'; +import { UserQueryRepositoryImpl } from '../persistence/repositories/user-query.repository.impl'; +import { USER_QUERY_REPOSITORY } from '../../domain/repositories/user-query.repository'; +import { UserEventConsumerService } from './user-event-consumer.service'; + +@Module({ + imports: [ConfigModule], + providers: [ + PrismaService, + { + provide: USER_QUERY_REPOSITORY, + useClass: UserQueryRepositoryImpl, + }, + UserEventConsumerService, + ], + exports: [UserEventConsumerService, USER_QUERY_REPOSITORY], +}) +export class KafkaModule {} diff --git a/backend/services/admin-service/src/infrastructure/kafka/user-event-consumer.service.ts b/backend/services/admin-service/src/infrastructure/kafka/user-event-consumer.service.ts new file mode 100644 index 00000000..9de7ccbc --- /dev/null +++ b/backend/services/admin-service/src/infrastructure/kafka/user-event-consumer.service.ts @@ -0,0 +1,407 @@ +import { Injectable, Logger, OnModuleInit, OnModuleDestroy } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { Kafka, Consumer, logLevel, EachMessagePayload } from 'kafkajs'; +import { PrismaService } from '../persistence/prisma/prisma.service'; +import { IUserQueryRepository, USER_QUERY_REPOSITORY } from '../../domain/repositories/user-query.repository'; +import { Inject } from '@nestjs/common'; + +/** + * 用户事件 Payload 类型定义 + */ +interface UserAccountCreatedPayload { + userId: string; + accountSequence: string; + referralCode: string; + phoneNumber?: string; + initialDeviceId: string; + inviterSequence: string | null; + registeredAt: string; + _outbox?: { + id: string; + aggregateId: string; + eventType: string; + }; +} + +interface UserAccountAutoCreatedPayload { + userId: string; + accountSequence: string; + referralCode: string; + initialDeviceId: string; + inviterSequence: string | null; + registeredAt: string; + _outbox?: { + id: string; + aggregateId: string; + eventType: string; + }; +} + +interface UserProfileUpdatedPayload { + userId: string; + accountSequence: string; + nickname: string | null; + avatarUrl: string | null; + updatedAt: string; + _outbox?: { + id: string; + aggregateId: string; + eventType: string; + }; +} + +interface KYCVerifiedPayload { + userId: string; + verifiedAt: string; + _outbox?: { + id: string; + aggregateId: string; + eventType: string; + }; +} + +interface KYCRejectedPayload { + userId: string; + reason: string; + _outbox?: { + id: string; + aggregateId: string; + eventType: string; + }; +} + +interface UserAccountFrozenPayload { + userId: string; + reason: string; + _outbox?: { + id: string; + aggregateId: string; + eventType: string; + }; +} + +interface UserAccountDeactivatedPayload { + userId: string; + deactivatedAt: string; + _outbox?: { + id: string; + aggregateId: string; + eventType: string; + }; +} + +/** + * 用户事件消费者服务 + * + * 消费 identity-service 发布的用户相关事件,同步更新本地 UserQueryView + */ +@Injectable() +export class UserEventConsumerService implements OnModuleInit, OnModuleDestroy { + private readonly logger = new Logger(UserEventConsumerService.name); + private kafka: Kafka; + private consumer: Consumer; + private isRunning = false; + + // 配置 + private readonly topics: string[]; + private readonly consumerGroup: string; + private readonly ackTopic: string; + + constructor( + private readonly configService: ConfigService, + private readonly prisma: PrismaService, + @Inject(USER_QUERY_REPOSITORY) + private readonly userQueryRepository: IUserQueryRepository, + ) { + const brokers = (this.configService.get('KAFKA_BROKERS', 'localhost:9092')).split(','); + const clientId = this.configService.get('KAFKA_CLIENT_ID', 'admin-service'); + this.consumerGroup = this.configService.get('KAFKA_CONSUMER_GROUP', 'admin-service-user-sync'); + this.ackTopic = 'identity.events.ack'; + + // 订阅的主题 + this.topics = ['identity.events']; + + this.kafka = new Kafka({ + clientId, + brokers, + logLevel: logLevel.WARN, + }); + + this.consumer = this.kafka.consumer({ groupId: this.consumerGroup }); + + this.logger.log(`[UserEventConsumer] Configured with topics: ${this.topics.join(', ')}`); + } + + async onModuleInit() { + await this.start(); + } + + async onModuleDestroy() { + await this.stop(); + } + + async start(): Promise { + if (this.isRunning) { + this.logger.warn('[UserEventConsumer] Already running'); + return; + } + + try { + this.logger.log('[UserEventConsumer] Connecting to Kafka...'); + await this.consumer.connect(); + + for (const topic of this.topics) { + await this.consumer.subscribe({ topic, fromBeginning: false }); + this.logger.log(`[UserEventConsumer] Subscribed to topic: ${topic}`); + } + + await this.consumer.run({ + eachMessage: async (payload: EachMessagePayload) => { + await this.handleMessage(payload); + }, + }); + + this.isRunning = true; + this.logger.log('[UserEventConsumer] Started successfully'); + } catch (error) { + this.logger.error('[UserEventConsumer] Failed to start:', error); + } + } + + async stop(): Promise { + if (!this.isRunning) return; + + try { + await this.consumer.disconnect(); + this.isRunning = false; + this.logger.log('[UserEventConsumer] Stopped'); + } catch (error) { + this.logger.error('[UserEventConsumer] Failed to stop:', error); + } + } + + private async handleMessage(payload: EachMessagePayload): Promise { + const { topic, partition, message } = payload; + + if (!message.value) { + this.logger.warn(`[UserEventConsumer] Empty message from ${topic}:${partition}`); + return; + } + + try { + const eventData = JSON.parse(message.value.toString()); + const eventType = eventData._outbox?.eventType || eventData.eventType; + const eventId = eventData._outbox?.id || message.key?.toString(); + + this.logger.debug(`[UserEventConsumer] Received event: ${eventType} (${eventId})`); + + // 幂等性检查 + if (eventId && await this.isEventProcessed(eventId)) { + this.logger.debug(`[UserEventConsumer] Event ${eventId} already processed, skipping`); + return; + } + + // 处理事件 + await this.processEvent(eventType, eventData); + + // 记录已处理事件 + if (eventId) { + await this.markEventProcessed(eventId, eventType); + } + + // 发送确认消息 (B方案) + if (eventData._outbox?.id) { + await this.sendAck(eventData._outbox.id, eventType); + } + + this.logger.log(`[UserEventConsumer] ✓ Processed event: ${eventType}`); + } catch (error) { + this.logger.error(`[UserEventConsumer] Failed to process message:`, error); + // 不抛出错误,避免阻塞消费 + } + } + + private async processEvent(eventType: string, payload: unknown): Promise { + switch (eventType) { + case 'UserAccountCreated': + await this.handleUserAccountCreated(payload as UserAccountCreatedPayload); + break; + + case 'UserAccountAutoCreated': + await this.handleUserAccountAutoCreated(payload as UserAccountAutoCreatedPayload); + break; + + case 'UserProfileUpdated': + await this.handleUserProfileUpdated(payload as UserProfileUpdatedPayload); + break; + + case 'KYCVerified': + await this.handleKYCVerified(payload as KYCVerifiedPayload); + break; + + case 'KYCRejected': + await this.handleKYCRejected(payload as KYCRejectedPayload); + break; + + case 'UserAccountFrozen': + await this.handleUserAccountFrozen(payload as UserAccountFrozenPayload); + break; + + case 'UserAccountDeactivated': + await this.handleUserAccountDeactivated(payload as UserAccountDeactivatedPayload); + break; + + default: + this.logger.debug(`[UserEventConsumer] Unknown event type: ${eventType}, skipping`); + } + } + + // ==================== Event Handlers ==================== + + private async handleUserAccountCreated(payload: UserAccountCreatedPayload): Promise { + const phoneNumberMasked = payload.phoneNumber + ? this.maskPhoneNumber(payload.phoneNumber) + : null; + + await this.userQueryRepository.upsert({ + userId: BigInt(payload.userId), + accountSequence: payload.accountSequence, + phoneNumberMasked, + inviterSequence: payload.inviterSequence, + registeredAt: new Date(payload.registeredAt), + }); + + this.logger.log(`[UserEventConsumer] Created user: ${payload.accountSequence}`); + } + + private async handleUserAccountAutoCreated(payload: UserAccountAutoCreatedPayload): Promise { + await this.userQueryRepository.upsert({ + userId: BigInt(payload.userId), + accountSequence: payload.accountSequence, + inviterSequence: payload.inviterSequence, + registeredAt: new Date(payload.registeredAt), + }); + + this.logger.log(`[UserEventConsumer] Auto-created user: ${payload.accountSequence}`); + } + + private async handleUserProfileUpdated(payload: UserProfileUpdatedPayload): Promise { + const userId = BigInt(payload.userId); + + // 检查用户是否存在 + const exists = await this.userQueryRepository.exists(userId); + if (!exists) { + this.logger.warn(`[UserEventConsumer] User ${userId} not found, skipping profile update`); + return; + } + + await this.userQueryRepository.updateProfile(userId, { + nickname: payload.nickname, + avatarUrl: payload.avatarUrl, + }); + + this.logger.log(`[UserEventConsumer] Updated profile for user: ${payload.accountSequence}`); + } + + private async handleKYCVerified(payload: KYCVerifiedPayload): Promise { + const userId = BigInt(payload.userId); + + const exists = await this.userQueryRepository.exists(userId); + if (!exists) { + this.logger.warn(`[UserEventConsumer] User ${userId} not found, skipping KYC update`); + return; + } + + await this.userQueryRepository.updateKycStatus(userId, 'VERIFIED'); + this.logger.log(`[UserEventConsumer] KYC verified for user: ${userId}`); + } + + private async handleKYCRejected(payload: KYCRejectedPayload): Promise { + const userId = BigInt(payload.userId); + + const exists = await this.userQueryRepository.exists(userId); + if (!exists) { + this.logger.warn(`[UserEventConsumer] User ${userId} not found, skipping KYC update`); + return; + } + + await this.userQueryRepository.updateKycStatus(userId, 'REJECTED'); + this.logger.log(`[UserEventConsumer] KYC rejected for user: ${userId}`); + } + + private async handleUserAccountFrozen(payload: UserAccountFrozenPayload): Promise { + const userId = BigInt(payload.userId); + + const exists = await this.userQueryRepository.exists(userId); + if (!exists) { + this.logger.warn(`[UserEventConsumer] User ${userId} not found, skipping status update`); + return; + } + + await this.userQueryRepository.updateStatus(userId, 'FROZEN'); + this.logger.log(`[UserEventConsumer] User frozen: ${userId}`); + } + + private async handleUserAccountDeactivated(payload: UserAccountDeactivatedPayload): Promise { + const userId = BigInt(payload.userId); + + const exists = await this.userQueryRepository.exists(userId); + if (!exists) { + this.logger.warn(`[UserEventConsumer] User ${userId} not found, skipping status update`); + return; + } + + await this.userQueryRepository.updateStatus(userId, 'DEACTIVATED'); + this.logger.log(`[UserEventConsumer] User deactivated: ${userId}`); + } + + // ==================== Helper Methods ==================== + + private maskPhoneNumber(phone: string): string { + if (phone.length < 7) return phone; + return phone.slice(0, 3) + '****' + phone.slice(-4); + } + + private async isEventProcessed(eventId: string): Promise { + const count = await this.prisma.processedEvent.count({ + where: { eventId }, + }); + return count > 0; + } + + private async markEventProcessed(eventId: string, eventType: string): Promise { + await this.prisma.processedEvent.create({ + data: { + eventId, + eventType, + processedAt: new Date(), + }, + }); + } + + private async sendAck(outboxId: string, eventType: string): Promise { + try { + const producer = this.kafka.producer(); + await producer.connect(); + await producer.send({ + topic: this.ackTopic, + messages: [ + { + key: outboxId, + value: JSON.stringify({ + outboxId, + eventType, + consumerId: this.consumerGroup, + confirmedAt: new Date().toISOString(), + }), + }, + ], + }); + await producer.disconnect(); + + this.logger.debug(`[UserEventConsumer] Sent ACK for outbox event ${outboxId}`); + } catch (error) { + this.logger.error(`[UserEventConsumer] Failed to send ACK:`, error); + } + } +} diff --git a/backend/services/admin-service/src/infrastructure/persistence/repositories/user-query.repository.impl.ts b/backend/services/admin-service/src/infrastructure/persistence/repositories/user-query.repository.impl.ts new file mode 100644 index 00000000..71bc5ae1 --- /dev/null +++ b/backend/services/admin-service/src/infrastructure/persistence/repositories/user-query.repository.impl.ts @@ -0,0 +1,317 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { PrismaService } from '../prisma/prisma.service'; +import { + IUserQueryRepository, + UserQueryItem, + UserQueryFilters, + UserQueryPagination, + UserQuerySort, + UserQueryResult, +} from '../../../domain/repositories/user-query.repository'; +import { Prisma } from '@prisma/client'; + +@Injectable() +export class UserQueryRepositoryImpl implements IUserQueryRepository { + private readonly logger = new Logger(UserQueryRepositoryImpl.name); + + constructor(private readonly prisma: PrismaService) {} + + async findMany( + filters: UserQueryFilters, + pagination: UserQueryPagination, + sort?: UserQuerySort, + ): Promise { + const where = this.buildWhereClause(filters); + const orderBy = this.buildOrderBy(sort); + + const [items, total] = await Promise.all([ + this.prisma.userQueryView.findMany({ + where, + orderBy, + skip: (pagination.page - 1) * pagination.pageSize, + take: pagination.pageSize, + }), + this.prisma.userQueryView.count({ where }), + ]); + + return { + items: items.map(this.mapToQueryItem), + total, + page: pagination.page, + pageSize: pagination.pageSize, + totalPages: Math.ceil(total / pagination.pageSize), + }; + } + + async findById(userId: bigint): Promise { + const item = await this.prisma.userQueryView.findUnique({ + where: { userId }, + }); + + return item ? this.mapToQueryItem(item) : null; + } + + async findByAccountSequence(accountSequence: string): Promise { + const item = await this.prisma.userQueryView.findUnique({ + where: { accountSequence }, + }); + + return item ? this.mapToQueryItem(item) : null; + } + + async upsert(data: { + userId: bigint; + accountSequence: string; + nickname?: string | null; + avatarUrl?: string | null; + phoneNumberMasked?: string | null; + inviterSequence?: string | null; + kycStatus?: string; + status?: string; + registeredAt: Date; + }): Promise { + await this.prisma.userQueryView.upsert({ + where: { userId: data.userId }, + create: { + userId: data.userId, + accountSequence: data.accountSequence, + nickname: data.nickname ?? null, + avatarUrl: data.avatarUrl ?? null, + phoneNumberMasked: data.phoneNumberMasked ?? null, + inviterSequence: data.inviterSequence ?? null, + kycStatus: data.kycStatus ?? 'NOT_VERIFIED', + status: data.status ?? 'ACTIVE', + registeredAt: data.registeredAt, + syncedAt: new Date(), + }, + update: { + accountSequence: data.accountSequence, + nickname: data.nickname !== undefined ? data.nickname : undefined, + avatarUrl: data.avatarUrl !== undefined ? data.avatarUrl : undefined, + phoneNumberMasked: data.phoneNumberMasked !== undefined ? data.phoneNumberMasked : undefined, + inviterSequence: data.inviterSequence !== undefined ? data.inviterSequence : undefined, + kycStatus: data.kycStatus !== undefined ? data.kycStatus : undefined, + status: data.status !== undefined ? data.status : undefined, + syncedAt: new Date(), + }, + }); + + this.logger.debug(`[UserQueryView] Upserted user ${data.accountSequence}`); + } + + async updateProfile( + userId: bigint, + data: { + nickname?: string | null; + avatarUrl?: string | null; + }, + ): Promise { + await this.prisma.userQueryView.update({ + where: { userId }, + data: { + ...data, + syncedAt: new Date(), + }, + }); + + this.logger.debug(`[UserQueryView] Updated profile for user ${userId}`); + } + + async updateAdoptionStats( + userId: bigint, + data: { + personalAdoptionCount?: number; + teamAddressCount?: number; + teamAdoptionCount?: number; + }, + ): Promise { + await this.prisma.userQueryView.update({ + where: { userId }, + data: { + ...data, + syncedAt: new Date(), + }, + }); + + this.logger.debug(`[UserQueryView] Updated adoption stats for user ${userId}`); + } + + async updateAuthorizationStats( + userId: bigint, + data: { + provinceAdoptionCount?: number; + cityAdoptionCount?: number; + }, + ): Promise { + await this.prisma.userQueryView.update({ + where: { userId }, + data: { + ...data, + syncedAt: new Date(), + }, + }); + + this.logger.debug(`[UserQueryView] Updated authorization stats for user ${userId}`); + } + + async updateStatus(userId: bigint, status: string): Promise { + await this.prisma.userQueryView.update({ + where: { userId }, + data: { + status, + syncedAt: new Date(), + }, + }); + + this.logger.debug(`[UserQueryView] Updated status for user ${userId} to ${status}`); + } + + async updateKycStatus(userId: bigint, kycStatus: string): Promise { + await this.prisma.userQueryView.update({ + where: { userId }, + data: { + kycStatus, + syncedAt: new Date(), + }, + }); + + this.logger.debug(`[UserQueryView] Updated KYC status for user ${userId} to ${kycStatus}`); + } + + async updateOnlineStatus(userId: bigint, isOnline: boolean): Promise { + await this.prisma.userQueryView.update({ + where: { userId }, + data: { + isOnline, + lastActiveAt: isOnline ? new Date() : undefined, + syncedAt: new Date(), + }, + }); + } + + async batchUpdateOnlineStatus(userIds: bigint[], isOnline: boolean): Promise { + await this.prisma.userQueryView.updateMany({ + where: { userId: { in: userIds } }, + data: { + isOnline, + lastActiveAt: isOnline ? new Date() : undefined, + syncedAt: new Date(), + }, + }); + + this.logger.debug(`[UserQueryView] Batch updated online status for ${userIds.length} users`); + } + + async count(filters?: UserQueryFilters): Promise { + const where = filters ? this.buildWhereClause(filters) : {}; + return this.prisma.userQueryView.count({ where }); + } + + async exists(userId: bigint): Promise { + const count = await this.prisma.userQueryView.count({ + where: { userId }, + }); + return count > 0; + } + + // ==================== Private Methods ==================== + + private buildWhereClause(filters: UserQueryFilters): Prisma.UserQueryViewWhereInput { + const where: Prisma.UserQueryViewWhereInput = {}; + + // 关键词搜索 (账号序列号/昵称) + if (filters.keyword) { + where.OR = [ + { accountSequence: { contains: filters.keyword, mode: 'insensitive' } }, + { nickname: { contains: filters.keyword, mode: 'insensitive' } }, + ]; + } + + // 状态筛选 + if (filters.status) { + where.status = filters.status; + } + + // KYC状态筛选 + if (filters.kycStatus) { + where.kycStatus = filters.kycStatus; + } + + // 是否有推荐人 + if (filters.hasInviter !== undefined) { + where.inviterSequence = filters.hasInviter ? { not: null } : null; + } + + // 认种数范围 + if (filters.minAdoptions !== undefined || filters.maxAdoptions !== undefined) { + where.personalAdoptionCount = {}; + if (filters.minAdoptions !== undefined) { + where.personalAdoptionCount.gte = filters.minAdoptions; + } + if (filters.maxAdoptions !== undefined) { + where.personalAdoptionCount.lte = filters.maxAdoptions; + } + } + + // 注册时间范围 + if (filters.registeredAfter || filters.registeredBefore) { + where.registeredAt = {}; + if (filters.registeredAfter) { + where.registeredAt.gte = filters.registeredAfter; + } + if (filters.registeredBefore) { + where.registeredAt.lte = filters.registeredBefore; + } + } + + return where; + } + + private buildOrderBy(sort?: UserQuerySort): Prisma.UserQueryViewOrderByWithRelationInput { + if (!sort) { + return { registeredAt: 'desc' }; + } + + return { [sort.field]: sort.order }; + } + + private mapToQueryItem(item: { + userId: bigint; + accountSequence: string; + nickname: string | null; + avatarUrl: string | null; + phoneNumberMasked: string | null; + inviterSequence: string | null; + kycStatus: string; + personalAdoptionCount: number; + teamAddressCount: number; + teamAdoptionCount: number; + provinceAdoptionCount: number; + cityAdoptionCount: number; + leaderboardRank: number | null; + status: string; + isOnline: boolean; + registeredAt: Date; + lastActiveAt: Date | null; + }): UserQueryItem { + return { + userId: item.userId, + accountSequence: item.accountSequence, + nickname: item.nickname, + avatarUrl: item.avatarUrl, + phoneNumberMasked: item.phoneNumberMasked, + inviterSequence: item.inviterSequence, + kycStatus: item.kycStatus, + personalAdoptionCount: item.personalAdoptionCount, + teamAddressCount: item.teamAddressCount, + teamAdoptionCount: item.teamAdoptionCount, + provinceAdoptionCount: item.provinceAdoptionCount, + cityAdoptionCount: item.cityAdoptionCount, + leaderboardRank: item.leaderboardRank, + status: item.status, + isOnline: item.isOnline, + registeredAt: item.registeredAt, + lastActiveAt: item.lastActiveAt, + }; + } +} diff --git a/backend/services/identity-service/src/application/services/user-application.service.ts b/backend/services/identity-service/src/application/services/user-application.service.ts index 7f7dfa5d..e88f5932 100644 --- a/backend/services/identity-service/src/application/services/user-application.service.ts +++ b/backend/services/identity-service/src/application/services/user-application.service.ts @@ -21,7 +21,7 @@ import { BlockchainWalletHandler } from '../event-handlers/blockchain-wallet.han import { MpcKeygenCompletedHandler } from '../event-handlers/mpc-keygen-completed.handler'; import { ApplicationError } from '@/shared/exceptions/domain.exception'; import { generateIdentity } from '@/shared/utils'; -import { MpcKeygenRequestedEvent, AccountRecoveredEvent, AccountRecoveryFailedEvent, MnemonicRevokedEvent, AccountUnfrozenEvent, KeyRotationRequestedEvent } from '@/domain/events'; +import { MpcKeygenRequestedEvent, AccountRecoveredEvent, AccountRecoveryFailedEvent, MnemonicRevokedEvent, AccountUnfrozenEvent, KeyRotationRequestedEvent, UserProfileUpdatedEvent } from '@/domain/events'; import { AutoCreateAccountCommand, RecoverByMnemonicCommand, RecoverByPhoneCommand, AutoLoginCommand, RegisterCommand, LoginCommand, BindPhoneNumberCommand, @@ -480,6 +480,16 @@ export class UserApplicationService { }); await this.userRepository.save(account); + + // 发布用户资料更新事件 + const event = new UserProfileUpdatedEvent({ + userId: account.id.value.toString(), + accountSequence: account.accountSequence.value, + nickname: account.nickname, + avatarUrl: account.avatarUrl, + updatedAt: new Date(), + }); + await this.eventPublisher.publishAll([event]); } async submitKYC(command: SubmitKYCCommand): Promise { diff --git a/backend/services/identity-service/src/domain/events/index.ts b/backend/services/identity-service/src/domain/events/index.ts index d1e152fe..d77a8815 100644 --- a/backend/services/identity-service/src/domain/events/index.ts +++ b/backend/services/identity-service/src/domain/events/index.ts @@ -161,6 +161,28 @@ export class UserAccountDeactivatedEvent extends DomainEvent { } } +/** + * 用户资料更新事件 + * 当用户更新昵称或头像时发布 + */ +export class UserProfileUpdatedEvent extends DomainEvent { + constructor( + public readonly payload: { + userId: string; + accountSequence: string; + nickname: string | null; + avatarUrl: string | null; + updatedAt: Date; + }, + ) { + super(); + } + + get eventType(): string { + return 'UserProfileUpdated'; + } +} + /** * MPC 密钥生成请求事件 * 用户创建账户后发布此事件,触发 MPC 服务生成钱包地址 diff --git a/frontend/admin-web/src/app/(dashboard)/users/page.tsx b/frontend/admin-web/src/app/(dashboard)/users/page.tsx index 3209afc0..b27262e6 100644 --- a/frontend/admin-web/src/app/(dashboard)/users/page.tsx +++ b/frontend/admin-web/src/app/(dashboard)/users/page.tsx @@ -1,158 +1,130 @@ 'use client'; -import { useState, useMemo } from 'react'; +import { useState, useCallback } from 'react'; import Image from 'next/image'; -import { Modal, toast } from '@/components/common'; +import { Modal, toast, Button } from '@/components/common'; import { PageContainer } from '@/components/layout'; import { cn } from '@/utils/helpers'; import { formatNumber, formatRanking } from '@/utils/formatters'; +import { useUsers, useUserDetail } from '@/hooks'; +import type { UserListItem } from '@/services/userService'; import styles from './users.module.scss'; -/** - * 用户数据类型 - */ -interface UserItem { - accountId: string; - avatar: string; - nickname: string; - personalAdoptions: number; - teamAddresses: number; - teamAdoptions: number; - provincialAdoptions: { count: number; percentage: number }; - cityAdoptions: { count: number; percentage: number }; - referrerId: string; - ranking: number | null; - status: 'online' | 'offline' | 'busy'; -} +// 骨架屏组件 +const TableRowSkeleton = () => ( +
+ {Array.from({ length: 12 }).map((_, i) => ( +
+
+
+ ))} +
+); -/** - * 模拟用户数据 - */ -const mockUsers: UserItem[] = [ - { - accountId: '1001', - avatar: '/images/Data@2x.png', - nickname: '春风', - personalAdoptions: 150, - teamAddresses: 3200, - teamAdoptions: 8500, - provincialAdoptions: { count: 2125, percentage: 25 }, - cityAdoptions: { count: 850, percentage: 10 }, - referrerId: '988', - ranking: 1, - status: 'busy', - }, - { - accountId: '1002', - avatar: '/images/Data1@2x.png', - nickname: '夏雨', - personalAdoptions: 120, - teamAddresses: 2800, - teamAdoptions: 7200, - provincialAdoptions: { count: 1800, percentage: 25 }, - cityAdoptions: { count: 1080, percentage: 15 }, - referrerId: '1001', - ranking: 31, - status: 'online', - }, - { - accountId: '1003', - avatar: '/images/Data1@2x.png', - nickname: '秋叶', - personalAdoptions: 95, - teamAddresses: 1500, - teamAdoptions: 4100, - provincialAdoptions: { count: 1025, percentage: 25 }, - cityAdoptions: { count: 410, percentage: 10 }, - referrerId: '1002', - ranking: null, - status: 'offline', - }, - ...Array.from({ length: 47 }, (_, i) => ({ - accountId: String(1004 + i), - avatar: '/images/Data@2x.png', - nickname: `用户${i + 4}`, - personalAdoptions: Math.floor(Math.random() * 100) + 50, - teamAddresses: Math.floor(Math.random() * 2000) + 500, - teamAdoptions: Math.floor(Math.random() * 5000) + 1000, - provincialAdoptions: { - count: Math.floor(Math.random() * 1000) + 200, - percentage: Math.floor(Math.random() * 30) + 10, - }, - cityAdoptions: { - count: Math.floor(Math.random() * 500) + 100, - percentage: Math.floor(Math.random() * 20) + 5, - }, - referrerId: String(Math.floor(Math.random() * 1000) + 1), - ranking: Math.random() > 0.3 ? Math.floor(Math.random() * 100) + 1 : null, - status: (['online', 'offline', 'busy'] as const)[Math.floor(Math.random() * 3)], - })), -]; +// 空数据提示 +const EmptyData = ({ message }: { message: string }) => ( +
+ {message} +
+); + +// 错误提示 +const ErrorMessage = ({ message, onRetry }: { message: string; onRetry?: () => void }) => ( +
+ {message} + {onRetry && ( + + )} +
+); /** * 用户管理页面 - * 基于 UIPro Figma 设计实现 + * 接入 admin-service 真实 API */ export default function UsersPage() { const [keyword, setKeyword] = useState(''); const [showFilters, setShowFilters] = useState(false); const [selectedRows, setSelectedRows] = useState([]); - const [detailModal, setDetailModal] = useState(null); + const [detailUserId, setDetailUserId] = useState(null); const [pagination, setPagination] = useState({ current: 1, pageSize: 10, }); - // 过滤后的数据 - const filteredData = useMemo(() => { - if (!keyword) return mockUsers; - return mockUsers.filter( - (user) => - user.accountId.includes(keyword) || - user.nickname.toLowerCase().includes(keyword.toLowerCase()) - ); - }, [keyword]); + // 使用 React Query hooks 获取用户列表 + const { + data: usersData, + isLoading, + error, + refetch, + } = useUsers({ + keyword: keyword || undefined, + page: pagination.current, + pageSize: pagination.pageSize, + sortBy: 'registeredAt', + sortOrder: 'desc', + }); - // 分页数据 - const paginatedData = useMemo(() => { - const start = (pagination.current - 1) * pagination.pageSize; - return filteredData.slice(start, start + pagination.pageSize); - }, [filteredData, pagination]); + // 获取用户详情 + const { + data: userDetail, + isLoading: detailLoading, + } = useUserDetail(detailUserId || ''); - // 总页数 - const totalPages = Math.ceil(filteredData.length / pagination.pageSize); + const users = usersData?.items ?? []; + const total = usersData?.total ?? 0; + const totalPages = usersData?.totalPages ?? 1; // 全选处理 - const handleSelectAll = (checked: boolean) => { + const handleSelectAll = useCallback((checked: boolean) => { if (checked) { - setSelectedRows(paginatedData.map((user) => user.accountId)); + setSelectedRows(users.map((user) => user.accountId)); } else { setSelectedRows([]); } - }; + }, [users]); // 单选处理 - const handleSelectRow = (accountId: string, checked: boolean) => { + const handleSelectRow = useCallback((accountId: string, checked: boolean) => { if (checked) { setSelectedRows((prev) => [...prev, accountId]); } else { setSelectedRows((prev) => prev.filter((id) => id !== accountId)); } - }; + }, []); + + // 搜索处理 + const handleSearch = useCallback((value: string) => { + setKeyword(value); + setPagination((prev) => ({ ...prev, current: 1 })); + }, []); // 导出 Excel - const handleExport = () => { + const handleExport = useCallback(() => { toast.success('导出功能开发中'); - }; + }, []); // 批量编辑 - const handleBatchEdit = () => { + const handleBatchEdit = useCallback(() => { if (selectedRows.length === 0) { toast.warning('请先选择用户'); return; } toast.success(`已选择 ${selectedRows.length} 位用户`); - }; + }, [selectedRows.length]); + + // 查看详情 + const handleViewDetail = useCallback((user: UserListItem) => { + setDetailUserId(user.accountId); + }, []); + + // 关闭详情弹窗 + const handleCloseDetail = useCallback(() => { + setDetailUserId(null); + }, []); // 生成分页按钮 const renderPaginationButtons = () => { @@ -276,6 +248,13 @@ export default function UsersPage() { return buttons; }; + // 获取状态显示 + const getStatusClass = (user: UserListItem) => { + if (user.isOnline) return 'online'; + if (user.status === 'frozen') return 'busy'; + return 'offline'; + }; + return (
@@ -308,7 +287,7 @@ export default function UsersPage() { placeholder="搜索账户ID、昵称" type="text" value={keyword} - onChange={(e) => setKeyword(e.target.value)} + onChange={(e) => handleSearch(e.target.value)} />
@@ -342,8 +321,8 @@ export default function UsersPage() {
)} @@ -358,7 +337,7 @@ export default function UsersPage() { 0} + checked={selectedRows.length === users.length && users.length > 0} onChange={(e) => handleSelectAll(e.target.checked)} /> @@ -399,112 +378,129 @@ export default function UsersPage() { {/* 表格内容 */}
- {paginatedData.map((user) => ( -
- {/* 复选框 */} -
- handleSelectRow(user.accountId, e.target.checked)} - /> -
- - {/* 账户序号 */} -
- {user.accountId} -
- - {/* 头像 */} -
-
-
-
-
- - {/* 昵称 */} -
- {user.nickname} -
- - {/* 账户认种量 */} -
- {formatNumber(user.personalAdoptions)} -
- - {/* 团队总注册地址量 */} -
- {formatNumber(user.teamAddresses)} -
- - {/* 团队总认种量 */} -
- {formatNumber(user.teamAdoptions)} -
- - {/* 团队本省认种量及占比 */} -
- {formatNumber(user.provincialAdoptions.count)} - - ({user.provincialAdoptions.percentage}%) - -
- - {/* 团队本市认种量及占比 */} -
- {formatNumber(user.cityAdoptions.count)} - - ({user.cityAdoptions.percentage}%) - -
- - {/* 推荐人序列号 */} -
- {user.referrerId} -
- - {/* 龙虎榜排名 */} + {isLoading ? ( + // 加载状态显示骨架屏 + Array.from({ length: pagination.pageSize }).map((_, i) => ( + + )) + ) : error ? ( + // 错误状态 + refetch()} + /> + ) : users.length === 0 ? ( + // 空数据状态 + + ) : ( + // 正常显示数据 + users.map((user) => (
- {user.ranking ? formatRanking(user.ranking) : '-'} -
+ {/* 复选框 */} +
+ handleSelectRow(user.accountId, e.target.checked)} + /> +
- {/* 操作 */} -
- - + {user.ranking ? formatRanking(user.ranking) : '-'} +
+ + {/* 操作 */} +
+ + +
-
- ))} + )) + )}
@@ -524,7 +520,7 @@ export default function UsersPage() { - 共 {filteredData.length} 条记录 + 共 {total} 条记录
{renderPaginationButtons()}
@@ -533,49 +529,61 @@ export default function UsersPage() { {/* 用户详情弹窗 */} setDetailModal(null)} + onClose={handleCloseDetail} footer={null} width={600} > - {detailModal && ( + {detailLoading ? ( +
加载中...
+ ) : userDetail ? (
-

{detailModal.nickname}

-

账户ID: {detailModal.accountId}

+

{userDetail.nickname || '未设置昵称'}

+

账户序号: {userDetail.accountSequence}

+

手机号: {userDetail.phoneNumberMasked || '未绑定'}

+

KYC状态: {userDetail.kycStatus}

个人认种量 - {formatNumber(detailModal.personalAdoptions)} + {formatNumber(userDetail.personalAdoptions)}
团队认种量 - {formatNumber(detailModal.teamAdoptions)} + {formatNumber(userDetail.teamAdoptions)}
龙虎榜排名 - {formatRanking(detailModal.ranking)} + {formatRanking(userDetail.ranking)}
+
+

注册时间: {new Date(userDetail.registeredAt).toLocaleString()}

+ {userDetail.lastActiveAt && ( +

最后活跃: {new Date(userDetail.lastActiveAt).toLocaleString()}

+ )} +
+ ) : ( +
未找到用户信息
)}
diff --git a/frontend/admin-web/src/hooks/index.ts b/frontend/admin-web/src/hooks/index.ts index b8353586..8f7ccc6d 100644 --- a/frontend/admin-web/src/hooks/index.ts +++ b/frontend/admin-web/src/hooks/index.ts @@ -1,3 +1,4 @@ // Hooks 统一导出 export * from './useDashboard'; +export * from './useUsers'; diff --git a/frontend/admin-web/src/hooks/useUsers.ts b/frontend/admin-web/src/hooks/useUsers.ts new file mode 100644 index 00000000..0196e57a --- /dev/null +++ b/frontend/admin-web/src/hooks/useUsers.ts @@ -0,0 +1,61 @@ +/** + * 用户管理 Hooks + * 使用 React Query 进行数据获取和缓存管理 + */ + +import { useQuery } from '@tanstack/react-query'; +import { userService, type UserListParams } from '@/services/userService'; + +/** Query Keys */ +export const userKeys = { + all: ['users'] as const, + list: (params: UserListParams) => [...userKeys.all, 'list', params] as const, + detail: (id: string) => [...userKeys.all, 'detail', id] as const, + stats: () => [...userKeys.all, 'stats'] as const, +}; + +/** + * 获取用户列表 + */ +export function useUsers(params: UserListParams = {}) { + return useQuery({ + queryKey: userKeys.list(params), + queryFn: async () => { + const response = await userService.getUsers(params); + return response.data; + }, + staleTime: 30 * 1000, // 30秒后标记为过期 + gcTime: 5 * 60 * 1000, // 5分钟后垃圾回收 + }); +} + +/** + * 获取用户详情 + */ +export function useUserDetail(id: string) { + return useQuery({ + queryKey: userKeys.detail(id), + queryFn: async () => { + const response = await userService.getUserDetail(id); + return response.data; + }, + enabled: !!id, // 只有在 id 存在时才查询 + staleTime: 60 * 1000, // 1分钟后标记为过期 + gcTime: 5 * 60 * 1000, + }); +} + +/** + * 获取用户统计 + */ +export function useUserStats() { + return useQuery({ + queryKey: userKeys.stats(), + queryFn: async () => { + const response = await userService.getUserStats(); + return response.data; + }, + staleTime: 60 * 1000, // 1分钟后标记为过期 + gcTime: 5 * 60 * 1000, + }); +} diff --git a/frontend/admin-web/src/infrastructure/api/endpoints.ts b/frontend/admin-web/src/infrastructure/api/endpoints.ts index 1e0db771..16fc4d34 100644 --- a/frontend/admin-web/src/infrastructure/api/endpoints.ts +++ b/frontend/admin-web/src/infrastructure/api/endpoints.ts @@ -10,14 +10,15 @@ export const API_ENDPOINTS = { REGISTER: '/auth/register', }, - // 用户管理 + // 用户管理 (admin-service) USERS: { - LIST: '/users', - DETAIL: (id: string) => `/users/${id}`, - UPDATE: (id: string) => `/users/${id}`, - DELETE: (id: string) => `/users/${id}`, - EXPORT: '/users/export', - BATCH_UPDATE: '/users/batch', + LIST: '/admin/users', + DETAIL: (id: string) => `/admin/users/${id}`, + STATS: '/admin/users/stats/summary', + UPDATE: (id: string) => `/admin/users/${id}`, + DELETE: (id: string) => `/admin/users/${id}`, + EXPORT: '/admin/users/export', + BATCH_UPDATE: '/admin/users/batch', }, // 龙虎榜 diff --git a/frontend/admin-web/src/services/userService.ts b/frontend/admin-web/src/services/userService.ts new file mode 100644 index 00000000..ee036814 --- /dev/null +++ b/frontend/admin-web/src/services/userService.ts @@ -0,0 +1,111 @@ +/** + * 用户管理服务 + * 负责用户数据的API调用 + */ + +import apiClient from '@/infrastructure/api/client'; +import { API_ENDPOINTS } from '@/infrastructure/api/endpoints'; +import type { ApiResponse } from '@/types'; + +/** 用户列表项 */ +export interface UserListItem { + accountId: string; + accountSequence: string; + avatar: string | null; + nickname: string | null; + personalAdoptions: number; + teamAddresses: number; + teamAdoptions: number; + provincialAdoptions: { + count: number; + percentage: number; + }; + cityAdoptions: { + count: number; + percentage: number; + }; + referrerId: string | null; + ranking: number | null; + status: 'active' | 'frozen' | 'deactivated'; + isOnline: boolean; +} + +/** 用户详情 */ +export interface UserDetail extends UserListItem { + phoneNumberMasked: string | null; + kycStatus: string; + registeredAt: string; + lastActiveAt: string | null; +} + +/** 用户列表响应 */ +export interface UserListResponse { + items: UserListItem[]; + total: number; + page: number; + pageSize: number; + totalPages: number; +} + +/** 用户统计 */ +export interface UserStats { + totalUsers: number; + activeUsers: number; + frozenUsers: number; + verifiedUsers: number; +} + +/** 用户列表查询参数 */ +export interface UserListParams { + keyword?: string; + status?: string; + kycStatus?: string; + hasInviter?: boolean; + minAdoptions?: number; + maxAdoptions?: number; + registeredAfter?: string; + registeredBefore?: string; + page?: number; + pageSize?: number; + sortBy?: string; + sortOrder?: 'asc' | 'desc'; +} + +/** + * 用户管理服务 + */ +export const userService = { + /** + * 获取用户列表 + */ + async getUsers(params: UserListParams = {}): Promise> { + return apiClient.get(API_ENDPOINTS.USERS.LIST, { params }); + }, + + /** + * 获取用户详情 + */ + async getUserDetail(id: string): Promise> { + return apiClient.get(API_ENDPOINTS.USERS.DETAIL(id)); + }, + + /** + * 获取用户统计 + */ + async getUserStats(): Promise> { + return apiClient.get(API_ENDPOINTS.USERS.STATS); + }, + + /** + * 导出用户数据 + */ + async exportUsers(params: UserListParams = {}): Promise { + const response = await apiClient.get(API_ENDPOINTS.USERS.EXPORT, { + params, + responseType: 'blob', + }); + return response.data as unknown as Blob; + }, +}; + +export default userService;