feat(admin): 实现用户管理功能完整前后端架构

## 概述
为 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/🆔 用户详情
- 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 <noreply@anthropic.com>
This commit is contained in:
hailin 2025-12-18 02:29:11 -08:00
parent 92850d8c62
commit ca619bff0b
17 changed files with 1751 additions and 229 deletions

View File

@ -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")
}

View File

@ -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<UserListResponseDto> {
// 构建筛选条件
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<UserDetailDto> {
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';
}
}
}

View File

@ -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
}

View File

@ -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;
}

View File

@ -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 {}

View File

@ -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<UserQueryResult>;
/**
* ID查询用户详情
*/
findById(userId: bigint): Promise<UserQueryItem | null>;
/**
*
*/
findByAccountSequence(accountSequence: string): Promise<UserQueryItem | null>;
/**
* ( 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<void>;
/**
*
*/
updateProfile(
userId: bigint,
data: {
nickname?: string | null;
avatarUrl?: string | null;
},
): Promise<void>;
/**
*
*/
updateAdoptionStats(
userId: bigint,
data: {
personalAdoptionCount?: number;
teamAddressCount?: number;
teamAdoptionCount?: number;
},
): Promise<void>;
/**
*
*/
updateAuthorizationStats(
userId: bigint,
data: {
provinceAdoptionCount?: number;
cityAdoptionCount?: number;
},
): Promise<void>;
/**
*
*/
updateStatus(userId: bigint, status: string): Promise<void>;
/**
* KYC
*/
updateKycStatus(userId: bigint, kycStatus: string): Promise<void>;
/**
* 线
*/
updateOnlineStatus(userId: bigint, isOnline: boolean): Promise<void>;
/**
* 线
*/
batchUpdateOnlineStatus(userIds: bigint[], isOnline: boolean): Promise<void>;
/**
*
*/
count(filters?: UserQueryFilters): Promise<number>;
/**
*
*/
exists(userId: bigint): Promise<boolean>;
}

View File

@ -0,0 +1,2 @@
export * from './kafka.module';
export * from './user-event-consumer.service';

View File

@ -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 {}

View File

@ -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<string>('KAFKA_BROKERS', 'localhost:9092')).split(',');
const clientId = this.configService.get<string>('KAFKA_CLIENT_ID', 'admin-service');
this.consumerGroup = this.configService.get<string>('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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<boolean> {
const count = await this.prisma.processedEvent.count({
where: { eventId },
});
return count > 0;
}
private async markEventProcessed(eventId: string, eventType: string): Promise<void> {
await this.prisma.processedEvent.create({
data: {
eventId,
eventType,
processedAt: new Date(),
},
});
}
private async sendAck(outboxId: string, eventType: string): Promise<void> {
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);
}
}
}

View File

@ -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<UserQueryResult> {
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<UserQueryItem | null> {
const item = await this.prisma.userQueryView.findUnique({
where: { userId },
});
return item ? this.mapToQueryItem(item) : null;
}
async findByAccountSequence(accountSequence: string): Promise<UserQueryItem | null> {
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
await this.prisma.userQueryView.update({
where: { userId },
data: {
isOnline,
lastActiveAt: isOnline ? new Date() : undefined,
syncedAt: new Date(),
},
});
}
async batchUpdateOnlineStatus(userIds: bigint[], isOnline: boolean): Promise<void> {
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<number> {
const where = filters ? this.buildWhereClause(filters) : {};
return this.prisma.userQueryView.count({ where });
}
async exists(userId: bigint): Promise<boolean> {
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,
};
}
}

View File

@ -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<void> {

View File

@ -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

View File

@ -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 = () => (
<div className={styles.users__tableRow}>
{Array.from({ length: 12 }).map((_, i) => (
<div key={i} className={styles.users__tableCellSkeleton}>
<div className={styles.users__skeleton} />
</div>
))}
</div>
);
/**
*
*/
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 }) => (
<div className={styles.users__empty}>
<span>{message}</span>
</div>
);
// 错误提示
const ErrorMessage = ({ message, onRetry }: { message: string; onRetry?: () => void }) => (
<div className={styles.users__error}>
<span>{message}</span>
{onRetry && (
<Button variant="outline" size="sm" onClick={onRetry}>
</Button>
)}
</div>
);
/**
*
* UIPro Figma
* admin-service API
*/
export default function UsersPage() {
const [keyword, setKeyword] = useState('');
const [showFilters, setShowFilters] = useState(false);
const [selectedRows, setSelectedRows] = useState<string[]>([]);
const [detailModal, setDetailModal] = useState<UserItem | null>(null);
const [detailUserId, setDetailUserId] = useState<string | null>(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 (
<PageContainer title="用户管理">
<div className={styles.users}>
@ -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)}
/>
</div>
@ -342,8 +321,8 @@ export default function UsersPage() {
</select>
<select className={styles.users__paginationSelect}>
<option value=""></option>
<option value="online">线</option>
<option value="offline">线</option>
<option value="active"></option>
<option value="frozen"></option>
</select>
</div>
)}
@ -358,7 +337,7 @@ export default function UsersPage() {
<input
type="checkbox"
className={styles.users__checkbox}
checked={selectedRows.length === paginatedData.length && paginatedData.length > 0}
checked={selectedRows.length === users.length && users.length > 0}
onChange={(e) => handleSelectAll(e.target.checked)}
/>
</div>
@ -399,112 +378,129 @@ export default function UsersPage() {
{/* 表格内容 */}
<div className={styles.users__tableBody}>
{paginatedData.map((user) => (
<div
key={user.accountId}
className={cn(
styles.users__tableRow,
selectedRows.includes(user.accountId) && styles['users__tableRow--selected']
)}
>
{/* 复选框 */}
<div className={cn(styles.users__tableCell, styles['users__tableCell--checkbox'])}>
<input
type="checkbox"
className={styles.users__checkbox}
checked={selectedRows.includes(user.accountId)}
onChange={(e) => handleSelectRow(user.accountId, e.target.checked)}
/>
</div>
{/* 账户序号 */}
<div className={cn(styles.users__tableCell, styles['users__tableCell--id'])}>
{user.accountId}
</div>
{/* 头像 */}
<div className={cn(styles.users__tableCell, styles['users__tableCell--avatar'])}>
<div
className={styles.users__avatar}
style={{ backgroundImage: `url(${user.avatar})` }}
>
<div
className={cn(
styles.users__avatarStatus,
styles[`users__avatarStatus--${user.status}`]
)}
/>
</div>
</div>
{/* 昵称 */}
<div className={cn(styles.users__tableCell, styles['users__tableCell--nickname'])}>
{user.nickname}
</div>
{/* 账户认种量 */}
<div className={cn(styles.users__tableCell, styles['users__tableCell--adoptions'])}>
{formatNumber(user.personalAdoptions)}
</div>
{/* 团队总注册地址量 */}
<div className={cn(styles.users__tableCell, styles['users__tableCell--teamAddress'])}>
{formatNumber(user.teamAddresses)}
</div>
{/* 团队总认种量 */}
<div className={cn(styles.users__tableCell, styles['users__tableCell--teamTotal'])}>
{formatNumber(user.teamAdoptions)}
</div>
{/* 团队本省认种量及占比 */}
<div className={cn(styles.users__tableCell, styles['users__tableCell--province'])}>
<span>{formatNumber(user.provincialAdoptions.count)}</span>
<span className={styles.users__percentage}>
({user.provincialAdoptions.percentage}%)
</span>
</div>
{/* 团队本市认种量及占比 */}
<div className={cn(styles.users__tableCell, styles['users__tableCell--city'])}>
<span>{formatNumber(user.cityAdoptions.count)}</span>
<span className={styles.users__percentage}>
({user.cityAdoptions.percentage}%)
</span>
</div>
{/* 推荐人序列号 */}
<div className={cn(styles.users__tableCell, styles['users__tableCell--referrer'])}>
{user.referrerId}
</div>
{/* 龙虎榜排名 */}
{isLoading ? (
// 加载状态显示骨架屏
Array.from({ length: pagination.pageSize }).map((_, i) => (
<TableRowSkeleton key={i} />
))
) : error ? (
// 错误状态
<ErrorMessage
message="加载用户数据失败"
onRetry={() => refetch()}
/>
) : users.length === 0 ? (
// 空数据状态
<EmptyData message="暂无用户数据" />
) : (
// 正常显示数据
users.map((user) => (
<div
key={user.accountId}
className={cn(
styles.users__tableCell,
styles['users__tableCell--ranking'],
user.ranking && user.ranking <= 10
? styles['users__tableCell--gold']
: styles['users__tableCell--normal']
styles.users__tableRow,
selectedRows.includes(user.accountId) && styles['users__tableRow--selected']
)}
>
{user.ranking ? formatRanking(user.ranking) : '-'}
</div>
{/* 复选框 */}
<div className={cn(styles.users__tableCell, styles['users__tableCell--checkbox'])}>
<input
type="checkbox"
className={styles.users__checkbox}
checked={selectedRows.includes(user.accountId)}
onChange={(e) => handleSelectRow(user.accountId, e.target.checked)}
/>
</div>
{/* 操作 */}
<div className={cn(styles.users__tableCell, styles['users__tableCell--actions'])}>
<button
className={styles.users__rowAction}
onClick={() => setDetailModal(user)}
{/* 账户序号 */}
<div className={cn(styles.users__tableCell, styles['users__tableCell--id'])}>
{user.accountSequence || user.accountId}
</div>
{/* 头像 */}
<div className={cn(styles.users__tableCell, styles['users__tableCell--avatar'])}>
<div
className={styles.users__avatar}
style={{ backgroundImage: `url(${user.avatar || '/images/Data@2x.png'})` }}
>
<div
className={cn(
styles.users__avatarStatus,
styles[`users__avatarStatus--${getStatusClass(user)}`]
)}
/>
</div>
</div>
{/* 昵称 */}
<div className={cn(styles.users__tableCell, styles['users__tableCell--nickname'])}>
{user.nickname || '-'}
</div>
{/* 账户认种量 */}
<div className={cn(styles.users__tableCell, styles['users__tableCell--adoptions'])}>
{formatNumber(user.personalAdoptions)}
</div>
{/* 团队总注册地址量 */}
<div className={cn(styles.users__tableCell, styles['users__tableCell--teamAddress'])}>
{formatNumber(user.teamAddresses)}
</div>
{/* 团队总认种量 */}
<div className={cn(styles.users__tableCell, styles['users__tableCell--teamTotal'])}>
{formatNumber(user.teamAdoptions)}
</div>
{/* 团队本省认种量及占比 */}
<div className={cn(styles.users__tableCell, styles['users__tableCell--province'])}>
<span>{formatNumber(user.provincialAdoptions.count)}</span>
<span className={styles.users__percentage}>
({user.provincialAdoptions.percentage}%)
</span>
</div>
{/* 团队本市认种量及占比 */}
<div className={cn(styles.users__tableCell, styles['users__tableCell--city'])}>
<span>{formatNumber(user.cityAdoptions.count)}</span>
<span className={styles.users__percentage}>
({user.cityAdoptions.percentage}%)
</span>
</div>
{/* 推荐人序列号 */}
<div className={cn(styles.users__tableCell, styles['users__tableCell--referrer'])}>
{user.referrerId || '-'}
</div>
{/* 龙虎榜排名 */}
<div
className={cn(
styles.users__tableCell,
styles['users__tableCell--ranking'],
user.ranking && user.ranking <= 10
? styles['users__tableCell--gold']
: styles['users__tableCell--normal']
)}
>
</button>
<button className={styles.users__rowAction}>
</button>
{user.ranking ? formatRanking(user.ranking) : '-'}
</div>
{/* 操作 */}
<div className={cn(styles.users__tableCell, styles['users__tableCell--actions'])}>
<button
className={styles.users__rowAction}
onClick={() => handleViewDetail(user)}
>
</button>
<button className={styles.users__rowAction}>
</button>
</div>
</div>
</div>
))}
))
)}
</div>
</div>
@ -524,7 +520,7 @@ export default function UsersPage() {
<option value={50}>50</option>
</select>
<span className={styles.users__paginationTotal}>
<span>{filteredData.length}</span>
<span>{total}</span>
</span>
</div>
<div className={styles.users__paginationList}>{renderPaginationButtons()}</div>
@ -533,49 +529,61 @@ export default function UsersPage() {
{/* 用户详情弹窗 */}
<Modal
visible={!!detailModal}
visible={!!detailUserId}
title="用户详情"
onClose={() => setDetailModal(null)}
onClose={handleCloseDetail}
footer={null}
width={600}
>
{detailModal && (
{detailLoading ? (
<div className={styles.userDetail__loading}>...</div>
) : userDetail ? (
<div className={styles.userDetail}>
<div className={styles.userDetail__header}>
<div
className={styles.users__avatar}
style={{
backgroundImage: `url(${detailModal.avatar})`,
backgroundImage: `url(${userDetail.avatar || '/images/Data@2x.png'})`,
width: 64,
height: 64,
}}
/>
<div className={styles.userDetail__info}>
<h3>{detailModal.nickname}</h3>
<p>ID: {detailModal.accountId}</p>
<h3>{userDetail.nickname || '未设置昵称'}</h3>
<p>: {userDetail.accountSequence}</p>
<p>: {userDetail.phoneNumberMasked || '未绑定'}</p>
<p>KYC状态: {userDetail.kycStatus}</p>
</div>
</div>
<div className={styles.userDetail__stats}>
<div className={styles.userDetail__statItem}>
<span className={styles.userDetail__statLabel}></span>
<span className={styles.userDetail__statValue}>
{formatNumber(detailModal.personalAdoptions)}
{formatNumber(userDetail.personalAdoptions)}
</span>
</div>
<div className={styles.userDetail__statItem}>
<span className={styles.userDetail__statLabel}></span>
<span className={styles.userDetail__statValue}>
{formatNumber(detailModal.teamAdoptions)}
{formatNumber(userDetail.teamAdoptions)}
</span>
</div>
<div className={styles.userDetail__statItem}>
<span className={styles.userDetail__statLabel}></span>
<span className={styles.userDetail__statValue}>
{formatRanking(detailModal.ranking)}
{formatRanking(userDetail.ranking)}
</span>
</div>
</div>
<div className={styles.userDetail__meta}>
<p>: {new Date(userDetail.registeredAt).toLocaleString()}</p>
{userDetail.lastActiveAt && (
<p>: {new Date(userDetail.lastActiveAt).toLocaleString()}</p>
)}
</div>
</div>
) : (
<div className={styles.userDetail__empty}></div>
)}
</Modal>
</div>

View File

@ -1,3 +1,4 @@
// Hooks 统一导出
export * from './useDashboard';
export * from './useUsers';

View File

@ -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,
});
}

View File

@ -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',
},
// 龙虎榜

View File

@ -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<ApiResponse<UserListResponse>> {
return apiClient.get(API_ENDPOINTS.USERS.LIST, { params });
},
/**
*
*/
async getUserDetail(id: string): Promise<ApiResponse<UserDetail>> {
return apiClient.get(API_ENDPOINTS.USERS.DETAIL(id));
},
/**
*
*/
async getUserStats(): Promise<ApiResponse<UserStats>> {
return apiClient.get(API_ENDPOINTS.USERS.STATS);
},
/**
*
*/
async exportUsers(params: UserListParams = {}): Promise<Blob> {
const response = await apiClient.get(API_ENDPOINTS.USERS.EXPORT, {
params,
responseType: 'blob',
});
return response.data as unknown as Blob;
},
};
export default userService;