feat: 算力记录排序 + P2P转账管理后台查询端点

## 算力记录排序(全栈)
- mining-admin-service controller: 新增 sortBy/sortOrder 查询参数
- users.service: 动态构建 orderBy(支持 sourceType, levelDepth, amount, createdAt)
  - levelDepth 排序特殊处理:先按 sourceType 分组,再按 levelDepth/bonusTier 排序,null 值排末尾
- admin-web contribution-records-list: 可点击排序表头(来源、获得算力、层级/等级、生效日期)
  - 三态切换:升序 → 降序 → 取消排序
  - 排序图标指示当前状态

## P2P转账管理后台查询端点(后端准备)
- trading-service P2pTransferService: 新增 getAllTransfers() 方法
  - 列出全部已完成 P2P 转账记录(分页 + 搜索)
  - 返回汇总统计:总手续费、总转账金额、总笔数
- trading-service controller: 新增 GET /p2p/internal/all-transfers(@Public 内部端点)
- P2pTransferHistoryItem 接口扩展 fromPhone/fromNickname/toNickname 字段

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
hailin 2026-01-30 10:37:22 -08:00
parent b1607666a0
commit 9f94344e8b
7 changed files with 190 additions and 9 deletions

View File

@ -52,15 +52,21 @@ export class UsersController {
@ApiParam({ name: 'accountSequence', type: String })
@ApiQuery({ name: 'page', required: false, type: Number })
@ApiQuery({ name: 'pageSize', required: false, type: Number })
@ApiQuery({ name: 'sortBy', required: false, type: String, description: '排序字段: sourceType, levelDepth, amount, createdAt' })
@ApiQuery({ name: 'sortOrder', required: false, type: String, description: '排序方向: asc, desc' })
async getUserContributions(
@Param('accountSequence') accountSequence: string,
@Query('page') page?: number,
@Query('pageSize') pageSize?: number,
@Query('sortBy') sortBy?: string,
@Query('sortOrder') sortOrder?: string,
) {
return this.usersService.getUserContributions(
accountSequence,
page ?? 1,
pageSize ?? 20,
sortBy,
sortOrder as 'asc' | 'desc' | undefined,
);
}

View File

@ -322,6 +322,8 @@ export class UsersService {
accountSequence: string,
page: number,
pageSize: number,
sortBy?: string,
sortOrder?: 'asc' | 'desc',
) {
const user = await this.prisma.syncedUser.findUnique({
where: { accountSequence },
@ -347,11 +349,32 @@ export class UsersService {
unlockedBonusTiers: 0,
};
// 构建排序条件
const allowedSortFields = ['sourceType', 'levelDepth', 'amount', 'createdAt'];
const order = sortOrder === 'asc' || sortOrder === 'desc' ? sortOrder : 'desc';
let orderBy: any;
if (sortBy && allowedSortFields.includes(sortBy)) {
// 按指定字段排序levelDepth 需要特殊处理null 值排到末尾)
if (sortBy === 'levelDepth') {
// 先按 sourceType 分组,再按 levelDepth/bonusTier 排序
orderBy = [
{ sourceType: order },
{ levelDepth: { sort: order, nulls: 'last' } },
{ bonusTier: { sort: order, nulls: 'last' } },
];
} else {
orderBy = { [sortBy]: order };
}
} else {
orderBy = { createdAt: 'desc' };
}
// 获取算力明细记录
const [records, total] = await Promise.all([
this.prisma.syncedContributionRecord.findMany({
where: { accountSequence },
orderBy: { createdAt: 'desc' },
orderBy,
skip: (page - 1) * pageSize,
take: pageSize,
}),

View File

@ -2,6 +2,7 @@ import { Controller, Get, Post, Body, Query, Param, Req, Headers, BadRequestExce
import { ApiTags, ApiOperation, ApiResponse, ApiBearerAuth, ApiQuery, ApiParam } from '@nestjs/swagger';
import { IsString, IsOptional, Length, Matches } from 'class-validator';
import { P2pTransferService } from '../../application/services/p2p-transfer.service';
import { Public } from '../../shared/guards/jwt-auth.guard';
class P2pTransferDto {
@IsString()
@ -81,4 +82,22 @@ export class P2pTransferController {
// TransformInterceptor 会自动包装成 { success: true, data: ... }
return result.data;
}
@Get('internal/all-transfers')
@Public()
@ApiOperation({ summary: '获取全部P2P转账记录内部调用管理后台用' })
@ApiQuery({ name: 'page', required: false, type: Number })
@ApiQuery({ name: 'pageSize', required: false, type: Number })
@ApiQuery({ name: 'search', required: false, type: String })
async getAllTransfers(
@Query('page') page?: number,
@Query('pageSize') pageSize?: number,
@Query('search') search?: string,
) {
return this.p2pTransferService.getAllTransfers(
page ?? 1,
pageSize ?? 20,
search,
);
}
}

View File

@ -29,8 +29,11 @@ export interface P2pTransferResult {
export interface P2pTransferHistoryItem {
transferNo: string;
fromAccountSequence: string;
fromPhone?: string | null;
fromNickname?: string | null;
toAccountSequence: string;
toPhone: string;
toNickname?: string | null;
amount: string;
fee?: string;
memo?: string | null;
@ -410,6 +413,73 @@ export class P2pTransferService {
return { data, total };
}
/**
* P2P转账记录
*/
async getAllTransfers(
page: number = 1,
pageSize: number = 20,
search?: string,
): Promise<{
data: P2pTransferHistoryItem[];
total: number;
summary: { totalFee: string; totalAmount: string; totalCount: number };
}> {
const validPage = Math.max(1, Number(page) || 1);
const validPageSize = Math.max(1, Math.min(100, Number(pageSize) || 20));
const where: any = { status: 'COMPLETED' };
if (search) {
where.OR = [
{ fromAccountSequence: { contains: search } },
{ toAccountSequence: { contains: search } },
{ toPhone: { contains: search } },
{ fromPhone: { contains: search } },
{ transferNo: { contains: search } },
];
}
const [records, total, agg] = await Promise.all([
this.prisma.p2pTransfer.findMany({
where,
orderBy: { createdAt: 'desc' },
skip: (validPage - 1) * validPageSize,
take: validPageSize,
}),
this.prisma.p2pTransfer.count({ where }),
this.prisma.p2pTransfer.aggregate({
where: { status: 'COMPLETED' },
_sum: { fee: true, amount: true },
_count: true,
}),
]);
const data: P2pTransferHistoryItem[] = records.map((record) => ({
transferNo: record.transferNo,
fromAccountSequence: record.fromAccountSequence,
fromPhone: record.fromPhone,
fromNickname: record.fromNickname,
toAccountSequence: record.toAccountSequence,
toPhone: record.toPhone,
toNickname: record.toNickname,
amount: record.amount.toString(),
fee: record.fee.toString(),
memo: record.memo,
status: record.status,
createdAt: record.createdAt,
}));
return {
data,
total,
summary: {
totalFee: agg._sum.fee?.toString() || '0',
totalAmount: agg._sum.amount?.toString() || '0',
totalCount: agg._count,
},
};
}
private generateTransferNo(): string {
const timestamp = Date.now().toString(36);
const random = Math.random().toString(36).substring(2, 8);

View File

@ -101,7 +101,7 @@ export const usersApi = {
getContributionRecords: async (
accountSequence: string,
params: PaginationParams
params: PaginationParams & { sortBy?: string; sortOrder?: 'asc' | 'desc' }
): Promise<PaginatedResponse<ContributionRecord>> => {
const response = await apiClient.get(`/users/${accountSequence}/contributions`, { params });
const result = response.data.data;

View File

@ -9,7 +9,10 @@ import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@
import { Button } from '@/components/ui/button';
import { Skeleton } from '@/components/ui/skeleton';
import { Badge } from '@/components/ui/badge';
import { ChevronLeft, ChevronRight } from 'lucide-react';
import { ChevronLeft, ChevronRight, ArrowUpDown, ArrowUp, ArrowDown } from 'lucide-react';
type SortField = 'sourceType' | 'levelDepth' | 'amount' | 'createdAt';
type SortOrder = 'asc' | 'desc';
const sourceTypeLabels: Record<string, string> = {
PERSONAL: '个人认种',
@ -23,6 +26,43 @@ const sourceTypeBadgeVariant: Record<string, 'default' | 'secondary' | 'outline'
TEAM_BONUS: 'outline',
};
function SortableHeader({
label,
field,
currentSort,
currentOrder,
onSort,
className,
}: {
label: string;
field: SortField;
currentSort?: SortField;
currentOrder: SortOrder;
onSort: (field: SortField) => void;
className?: string;
}) {
const isActive = currentSort === field;
return (
<TableHead className={className}>
<button
className="flex items-center gap-1 hover:text-foreground transition-colors -ml-1 px-1 py-0.5 rounded"
onClick={() => onSort(field)}
>
{label}
{isActive ? (
currentOrder === 'asc' ? (
<ArrowUp className="h-3.5 w-3.5 text-primary" />
) : (
<ArrowDown className="h-3.5 w-3.5 text-primary" />
)
) : (
<ArrowUpDown className="h-3.5 w-3.5 opacity-40" />
)}
</button>
</TableHead>
);
}
interface ContributionRecordsListProps {
accountSequence: string;
}
@ -30,8 +70,31 @@ interface ContributionRecordsListProps {
export function ContributionRecordsList({ accountSequence }: ContributionRecordsListProps) {
const [page, setPage] = useState(1);
const pageSize = 10;
const [sortBy, setSortBy] = useState<SortField | undefined>(undefined);
const [sortOrder, setSortOrder] = useState<SortOrder>('asc');
const { data, isLoading } = useContributionRecords(accountSequence, { page, pageSize });
const handleSort = (field: SortField) => {
if (sortBy === field) {
// 同一字段:切换方向 → 第三次点击取消排序
if (sortOrder === 'asc') {
setSortOrder('desc');
} else {
setSortBy(undefined);
setSortOrder('asc');
}
} else {
setSortBy(field);
setSortOrder('asc');
}
setPage(1);
};
const { data, isLoading } = useContributionRecords(accountSequence, {
page,
pageSize,
sortBy,
sortOrder: sortBy ? sortOrder : undefined,
});
return (
<Card>
@ -39,14 +102,14 @@ export function ContributionRecordsList({ accountSequence }: ContributionRecords
<Table>
<TableHeader>
<TableRow>
<TableHead></TableHead>
<SortableHeader label="来源" field="sourceType" currentSort={sortBy} currentOrder={sortOrder} onSort={handleSort} />
<TableHead></TableHead>
<TableHead className="text-right"></TableHead>
<TableHead className="text-right"></TableHead>
<TableHead className="text-right"></TableHead>
<TableHead className="text-right"></TableHead>
<TableHead>/</TableHead>
<TableHead></TableHead>
<SortableHeader label="获得算力" field="amount" currentSort={sortBy} currentOrder={sortOrder} onSort={handleSort} className="text-right" />
<SortableHeader label="层级/等级" field="levelDepth" currentSort={sortBy} currentOrder={sortOrder} onSort={handleSort} />
<SortableHeader label="生效日期" field="createdAt" currentSort={sortBy} currentOrder={sortOrder} onSort={handleSort} />
<TableHead></TableHead>
</TableRow>
</TableHeader>

View File

@ -17,7 +17,7 @@ export function useUserDetail(accountSequence: string) {
});
}
export function useContributionRecords(accountSequence: string, params: PaginationParams) {
export function useContributionRecords(accountSequence: string, params: PaginationParams & { sortBy?: string; sortOrder?: 'asc' | 'desc' }) {
return useQuery({
queryKey: ['users', accountSequence, 'contributions', params],
queryFn: () => usersApi.getContributionRecords(accountSequence, params),