225 lines
6.6 KiB
TypeScript
225 lines
6.6 KiB
TypeScript
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';
|
||
import {
|
||
IUserDetailQueryRepository,
|
||
USER_DETAIL_QUERY_REPOSITORY,
|
||
} from '../../domain/repositories/user-detail-query.repository';
|
||
|
||
/**
|
||
* 用户管理控制器
|
||
*
|
||
* 为 admin-web 提供用户查询接口
|
||
*/
|
||
@Controller('admin/users')
|
||
export class UserController {
|
||
constructor(
|
||
@Inject(USER_QUERY_REPOSITORY)
|
||
private readonly userQueryRepository: IUserQueryRepository,
|
||
@Inject(USER_DETAIL_QUERY_REPOSITORY)
|
||
private readonly userDetailRepository: IUserDetailQueryRepository,
|
||
) {}
|
||
|
||
/**
|
||
* 获取用户列表
|
||
* 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 accountSequences = result.items.map(item => item.accountSequence);
|
||
const statsMap = await this.userDetailRepository.getBatchUserStats(accountSequences);
|
||
|
||
// 获取所有用户的团队总认种数用于计算百分比(使用实时数据)
|
||
let totalTeamAdoptions = 0;
|
||
for (const stats of statsMap.values()) {
|
||
totalTeamAdoptions += stats.teamAdoptionCount;
|
||
}
|
||
|
||
return {
|
||
items: result.items.map((item) => this.mapToListItem(item, totalTeamAdoptions, statsMap.get(item.accountSequence))),
|
||
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,
|
||
realTimeStats?: {
|
||
personalAdoptionCount: number;
|
||
teamAddressCount: number;
|
||
teamAdoptionCount: number;
|
||
provinceAdoptionCount: number;
|
||
cityAdoptionCount: number;
|
||
},
|
||
): UserListItemDto {
|
||
// 使用实时统计数据(如果有),否则使用预计算数据
|
||
const personalAdoptions = realTimeStats?.personalAdoptionCount ?? item.personalAdoptionCount;
|
||
const teamAddresses = realTimeStats?.teamAddressCount ?? item.teamAddressCount;
|
||
const teamAdoptions = realTimeStats?.teamAdoptionCount ?? item.teamAdoptionCount;
|
||
const provinceAdoptions = realTimeStats?.provinceAdoptionCount ?? item.provinceAdoptionCount;
|
||
const cityAdoptions = realTimeStats?.cityAdoptionCount ?? item.cityAdoptionCount;
|
||
|
||
// 计算省市认种百分比(相对于该用户的团队总认种量)
|
||
const userTeamAdoptions = teamAdoptions > 0 ? teamAdoptions : 1;
|
||
const provincePercentage = teamAdoptions > 0
|
||
? Math.round((provinceAdoptions / userTeamAdoptions) * 100)
|
||
: 0;
|
||
const cityPercentage = teamAdoptions > 0
|
||
? Math.round((cityAdoptions / userTeamAdoptions) * 100)
|
||
: 0;
|
||
|
||
return {
|
||
accountId: item.userId.toString(),
|
||
accountSequence: item.accountSequence,
|
||
avatar: item.avatarUrl,
|
||
nickname: item.nickname,
|
||
phoneNumberMasked: item.phoneNumberMasked,
|
||
personalAdoptions,
|
||
teamAddresses,
|
||
teamAdoptions,
|
||
provincialAdoptions: {
|
||
count: provinceAdoptions,
|
||
percentage: provincePercentage,
|
||
},
|
||
cityAdoptions: {
|
||
count: cityAdoptions,
|
||
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,
|
||
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';
|
||
}
|
||
}
|
||
}
|