feat(admin-web): 添加过期收益明细查询功能

- reward-service: 添加 getExpiredRewardsEntries API 查询过期收益明细
- reporting-service: 添加过期收益明细转发接口和类型定义
- admin-web: 过期收益统计区域新增"查看明细"按钮
  - 支持分页浏览过期收益记录
  - 支持按权益类型筛选
  - 显示过期时间、用户ID、账户、权益类型、金额、订单号

回滚方式:删除各服务中标注 [2026-01-07] 的代码块

🤖 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 2026-01-06 22:36:31 -08:00
parent 90ca62b594
commit e9c0196d68
10 changed files with 421 additions and 3 deletions

View File

@ -79,6 +79,28 @@ export class SystemAccountReportController {
return this.systemAccountReportService.getExpiredRewardsSummary({ startDate, endDate });
}
// [2026-01-07] 新增:过期收益明细列表
// 用于系统账户报表中展示过期收益的具体记录
// 回滚方式:删除此方法
@Get('expired-rewards-entries')
@ApiOperation({ summary: '获取过期收益明细列表' })
@ApiQuery({ name: 'page', required: false, description: '页码默认1' })
@ApiQuery({ name: 'pageSize', required: false, description: '每页条数默认50' })
@ApiQuery({ name: 'rightType', required: false, description: '权益类型筛选(可选)' })
@ApiResponse({ status: 200, description: '过期收益明细列表' })
async getExpiredRewardsEntries(
@Query('page') page?: string,
@Query('pageSize') pageSize?: string,
@Query('rightType') rightType?: string,
) {
this.logger.log(`[getExpiredRewardsEntries] 请求过期收益明细, page=${page}, pageSize=${pageSize}, rightType=${rightType || 'all'}`);
return this.systemAccountReportService.getExpiredRewardsEntries({
page: page ? parseInt(page, 10) : undefined,
pageSize: pageSize ? parseInt(pageSize, 10) : undefined,
rightType,
});
}
// [2026-01-07] 新增:面对面结算明细列表
// 回滚方式:删除此方法
@Get('offline-settlement-entries')

View File

@ -7,7 +7,7 @@
*/
import { Injectable, Logger } from '@nestjs/common';
import { WalletServiceClient, OfflineSettlementSummary, AllSystemAccountsResponse, AllSystemAccountsLedgerResponse, FeeCollectionSummaryResponse, FeeCollectionEntriesResponse, OfflineSettlementEntriesResponse } from '../../infrastructure/external/wallet-service/wallet-service.client';
import { RewardServiceClient, ExpiredRewardsSummary, AllRewardTypeSummaries, RewardEntriesResponse } from '../../infrastructure/external/reward-service/reward-service.client';
import { RewardServiceClient, ExpiredRewardsSummary, AllRewardTypeSummaries, RewardEntriesResponse, ExpiredRewardsEntriesResponse } from '../../infrastructure/external/reward-service/reward-service.client';
/**
*
@ -185,6 +185,20 @@ export class SystemAccountReportApplicationService {
return this.rewardServiceClient.getExpiredRewardsSummary(params);
}
// [2026-01-07] 新增:获取过期收益明细列表
// 回滚方式:删除此方法
/**
*
*/
async getExpiredRewardsEntries(params?: {
page?: number;
pageSize?: number;
rightType?: string;
}): Promise<ExpiredRewardsEntriesResponse> {
this.logger.log('[getExpiredRewardsEntries] 获取过期收益明细列表');
return this.rewardServiceClient.getExpiredRewardsEntries(params);
}
// [2026-01-07] 新增:获取面对面结算明细列表
// 回滚方式:删除此方法
/**

View File

@ -83,6 +83,28 @@ export interface RewardEntryDTO {
expiredAt: string | null;
}
// [2026-01-07] 新增:过期收益明细条目
export interface ExpiredRewardEntryDTO {
id: string;
accountSequence: string;
userId: number;
sourceOrderId: string;
rightType: string;
usdtAmount: number;
hashpowerAmount: number;
createdAt: string;
expiredAt: string | null;
}
// [2026-01-07] 新增:过期收益明细响应
export interface ExpiredRewardsEntriesResponse {
entries: ExpiredRewardEntryDTO[];
total: number;
page: number;
pageSize: number;
totalPages: number;
}
// [2026-01-06] 新增:收益记录列表响应
export interface RewardEntriesResponse {
entries: RewardEntryDTO[];
@ -251,4 +273,39 @@ export class RewardServiceClient {
};
}
}
// [2026-01-07] 新增:获取过期收益明细列表
/**
*
*/
async getExpiredRewardsEntries(params?: {
page?: number;
pageSize?: number;
rightType?: string;
}): Promise<ExpiredRewardsEntriesResponse> {
try {
const queryParams = new URLSearchParams();
if (params?.page) queryParams.append('page', params.page.toString());
if (params?.pageSize) queryParams.append('pageSize', params.pageSize.toString());
if (params?.rightType) queryParams.append('rightType', params.rightType);
const url = `${this.baseUrl}/api/v1/internal/statistics/expired-rewards-entries?${queryParams.toString()}`;
this.logger.debug(`[getExpiredRewardsEntries] 请求: ${url}`);
const response = await firstValueFrom(
this.httpService.get<ExpiredRewardsEntriesResponse>(url),
);
return response.data;
} catch (error) {
this.logger.error(`[getExpiredRewardsEntries] 失败: ${error.message}`);
return {
entries: [],
total: 0,
page: 1,
pageSize: params?.pageSize ?? 50,
totalPages: 0,
};
}
}
}

View File

@ -167,4 +167,32 @@ export class InternalController {
this.logger.log(`手续费详细记录查询结果: total=${result.total}, page=${result.page}, pageSize=${result.pageSize}`);
return result;
}
// [2026-01-07] 新增:获取过期收益明细列表接口
// 用于系统账户报表中展示过期收益的具体记录
// 回滚方式:删除以下 API 方法即可
@Get('statistics/expired-rewards-entries')
@ApiOperation({ summary: '获取过期收益明细列表(内部接口)- 用于系统账户报表' })
@ApiQuery({ name: 'page', required: false, description: '页码默认1' })
@ApiQuery({ name: 'pageSize', required: false, description: '每页条数默认50' })
@ApiQuery({ name: 'rightType', required: false, description: '权益类型筛选(可选)' })
@ApiResponse({ status: 200, description: '过期收益明细列表' })
async getExpiredRewardsEntries(
@Query('page') page?: string,
@Query('pageSize') pageSize?: string,
@Query('rightType') rightType?: string,
) {
this.logger.log(`========== statistics/expired-rewards-entries 请求 ==========`);
this.logger.log(`page=${page}, pageSize=${pageSize}, rightType=${rightType || 'all'}`);
const result = await this.rewardService.getExpiredRewardsEntries({
page: page ? parseInt(page, 10) : undefined,
pageSize: pageSize ? parseInt(pageSize, 10) : undefined,
rightType: rightType || undefined,
});
this.logger.log(`过期收益明细查询结果: total=${result.total}, page=${result.page}, pageSize=${result.pageSize}`);
return result;
}
}

View File

@ -1468,4 +1468,80 @@ export class RewardApplicationService {
totalPages: Math.ceil(total / pageSize),
};
}
// [2026-01-07] 新增:获取过期收益明细列表
// 用于系统账户报表中展示过期收益的具体记录
// 回滚方式:删除以下方法即可
/**
*
* @param page
* @param pageSize
* @param rightType
*/
async getExpiredRewardsEntries(params: {
page?: number;
pageSize?: number;
rightType?: string;
}): Promise<{
entries: Array<{
id: string;
accountSequence: string;
userId: number;
sourceOrderId: string;
rightType: string;
usdtAmount: number;
hashpowerAmount: number;
createdAt: string;
expiredAt: string | null;
}>;
total: number;
page: number;
pageSize: number;
totalPages: number;
}> {
const page = params.page ?? 1;
const pageSize = params.pageSize ?? 50;
const skip = (page - 1) * pageSize;
this.logger.log(`[getExpiredRewardsEntries] 查询过期收益明细, page=${page}, pageSize=${pageSize}, rightType=${params.rightType || 'all'}`);
// 构建查询条件
const whereClause: any = {
rewardStatus: 'EXPIRED',
};
if (params.rightType) {
whereClause.rightType = params.rightType;
}
// 查询总数
const total = await this.prisma.rewardLedgerEntry.count({
where: whereClause,
});
// 查询数据
const entries = await this.prisma.rewardLedgerEntry.findMany({
where: whereClause,
orderBy: { expiredAt: 'desc' },
skip,
take: pageSize,
});
return {
entries: entries.map(entry => ({
id: entry.id.toString(),
accountSequence: entry.accountSequence,
userId: entry.userId,
sourceOrderId: entry.sourceOrderNo,
rightType: entry.rightType,
usdtAmount: Number(entry.usdtAmount),
hashpowerAmount: Number(entry.hashpowerAmount),
createdAt: entry.createdAt.toISOString(),
expiredAt: entry.expiredAt?.toISOString() ?? null,
})),
total,
page,
pageSize,
totalPages: Math.ceil(total / pageSize),
};
}
}

View File

@ -340,6 +340,40 @@
border-top: 1px solid #e5e7eb;
}
.entriesHeader {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 12px;
}
.filterGroup {
display: flex;
align-items: center;
gap: 8px;
font-size: 14px;
color: #374151;
label {
font-weight: 500;
}
}
.filterSelect {
padding: 6px 12px;
border: 1px solid #d1d5db;
border-radius: 6px;
font-size: 14px;
background-color: #fff;
cursor: pointer;
&:focus {
outline: none;
border-color: #3b82f6;
box-shadow: 0 0 0 2px rgba(59, 130, 246, 0.1);
}
}
.paginationInfo {
text-align: center;
padding: 12px;

View File

@ -25,6 +25,8 @@ import type {
FeeCollectionEntriesResponse,
OfflineSettlementEntriesResponse,
OfflineSettlementEntryDTO,
ExpiredRewardsEntriesResponse,
ExpiredRewardEntryDTO,
} from '@/types';
import { ENTRY_TYPE_LABELS, ACCOUNT_TYPE_LABELS, FEE_TYPE_LABELS, REWARD_RIGHT_TYPE_LABELS, REWARD_STATUS_LABELS, FEE_COLLECTION_TYPE_LABELS, getAccountDisplayName, SYSTEM_ACCOUNT_NAMES, PROVINCE_CODE_NAMES } from '@/types';
import styles from './SystemAccountsTab.module.scss';
@ -549,8 +551,58 @@ function OfflineSettlementSection({ data }: { data: SystemAccountReportResponse[
/**
*
* [2026-01-05] USDT改为绿积分
* [2026-01-07]
*/
function ExpiredRewardsSection({ data }: { data: SystemAccountReportResponse['expiredRewards'] | null | undefined }) {
const [showEntries, setShowEntries] = useState(false);
const [entriesData, setEntriesData] = useState<ExpiredRewardsEntriesResponse | null>(null);
const [entriesLoading, setEntriesLoading] = useState(false);
const [currentPage, setCurrentPage] = useState(1);
const [selectedRightType, setSelectedRightType] = useState<string>('');
const pageSize = 20;
// 加载明细数据
const loadEntries = useCallback(async (page: number, rightType?: string) => {
setEntriesLoading(true);
try {
const response = await systemAccountReportService.getExpiredRewardsEntries({
page,
pageSize,
rightType: rightType || undefined,
});
if (response.success && response.data) {
setEntriesData(response.data);
}
} catch (error) {
console.error('加载过期收益明细失败:', error);
} finally {
setEntriesLoading(false);
}
}, [pageSize]);
// 切换显示明细
const toggleEntries = () => {
if (!showEntries && !entriesData) {
loadEntries(1, selectedRightType);
}
setShowEntries(!showEntries);
};
// 切换权益类型筛选
const handleRightTypeChange = (rightType: string) => {
setSelectedRightType(rightType);
setCurrentPage(1);
if (showEntries) {
loadEntries(1, rightType);
}
};
// 分页
const handlePageChange = (page: number) => {
setCurrentPage(page);
loadEntries(page, selectedRightType);
};
if (!data) {
return (
<div className={styles.section}>
@ -562,7 +614,15 @@ function ExpiredRewardsSection({ data }: { data: SystemAccountReportResponse['ex
return (
<div className={styles.section}>
<h3 className={styles.sectionTitle}></h3>
<div className={styles.sectionHeader}>
<h3 className={styles.sectionTitle}></h3>
<button
onClick={toggleEntries}
className={styles.toggleButton}
>
{showEntries ? '收起明细' : '查看明细'}
</button>
</div>
{/* 汇总卡片 */}
<div className={styles.summaryCards}>
@ -630,7 +690,91 @@ function ExpiredRewardsSection({ data }: { data: SystemAccountReportResponse['ex
</>
)}
{(!data.byRightType || data.byRightType.length === 0) && (!data.byMonth || data.byMonth.length === 0) && (
{/* [2026-01-07] 新增:明细列表 */}
{showEntries && (
<div className={styles.entriesSection}>
<div className={styles.entriesHeader}>
<h4 className={styles.subTitle}></h4>
<div className={styles.filterGroup}>
<label>:</label>
<select
value={selectedRightType}
onChange={(e) => handleRightTypeChange(e.target.value)}
className={styles.filterSelect}
>
<option value=""></option>
{data.byRightType?.map((item) => (
<option key={item.rightType} value={item.rightType}>
{getRightTypeName(item.rightType)}
</option>
))}
</select>
</div>
</div>
{entriesLoading ? (
<div className={styles.loading}>
<div className={styles.spinner} />
<span>...</span>
</div>
) : entriesData && entriesData.entries.length > 0 ? (
<>
<div className={styles.tableWrapper}>
<table className={styles.table}>
<thead>
<tr>
<th></th>
<th>ID</th>
<th></th>
<th></th>
<th> (绿)</th>
<th></th>
</tr>
</thead>
<tbody>
{entriesData.entries.map((entry) => (
<tr key={entry.id}>
<td>{entry.expiredAt ? new Date(entry.expiredAt).toLocaleString('zh-CN') : '-'}</td>
<td>{entry.userId}</td>
<td>{getAccountDisplayName(entry.accountSequence)}</td>
<td>{getRightTypeName(entry.rightType)}</td>
<td>{formatAmount(entry.usdtAmount)}</td>
<td className={styles.orderId}>{entry.sourceOrderId}</td>
</tr>
))}
</tbody>
</table>
</div>
{/* 分页 */}
{entriesData.totalPages > 1 && (
<div className={styles.pagination}>
<button
onClick={() => handlePageChange(currentPage - 1)}
disabled={currentPage <= 1}
className={styles.pageButton}
>
</button>
<span className={styles.pageInfo}>
{currentPage} / {entriesData.totalPages} ( {entriesData.total} )
</span>
<button
onClick={() => handlePageChange(currentPage + 1)}
disabled={currentPage >= entriesData.totalPages}
className={styles.pageButton}
>
</button>
</div>
)}
</>
) : (
<div className={styles.emptyTable}></div>
)}
</div>
)}
{(!data.byRightType || data.byRightType.length === 0) && (!data.byMonth || data.byMonth.length === 0) && !showEntries && (
<div className={styles.emptyTable}></div>
)}
</div>

View File

@ -240,5 +240,7 @@ export const API_ENDPOINTS = {
FEE_COLLECTION_ENTRIES: '/v1/system-account-reports/fee-collection-entries',
// [2026-01-07] 新增:面对面结算明细列表
OFFLINE_SETTLEMENT_ENTRIES: '/v1/system-account-reports/offline-settlement-entries',
// [2026-01-07] 新增:过期收益明细列表
EXPIRED_REWARDS_ENTRIES: '/v1/system-account-reports/expired-rewards-entries',
},
} as const;

View File

@ -20,6 +20,7 @@ import type {
FeeCollectionSummaryResponse,
FeeCollectionEntriesResponse,
OfflineSettlementEntriesResponse,
ExpiredRewardsEntriesResponse,
} from '@/types';
/**
@ -89,6 +90,18 @@ export const systemAccountReportService = {
return apiClient.get(API_ENDPOINTS.SYSTEM_ACCOUNT_REPORTS.EXPIRED_REWARDS, { params });
},
// [2026-01-07] 新增:获取过期收益明细列表
/**
*
*/
async getExpiredRewardsEntries(params?: {
page?: number;
pageSize?: number;
rightType?: string;
}): Promise<ApiResponse<ExpiredRewardsEntriesResponse>> {
return apiClient.get(API_ENDPOINTS.SYSTEM_ACCOUNT_REPORTS.EXPIRED_REWARDS_ENTRIES, { params });
},
// [2026-01-05] 新增:获取所有系统账户分类账明细
/**
*

View File

@ -97,6 +97,34 @@ export interface ExpiredRewardsSummary {
}>;
}
// [2026-01-07] 新增:过期收益明细条目
/**
*
*/
export interface ExpiredRewardEntryDTO {
id: string;
accountSequence: string;
userId: number;
sourceOrderId: string;
rightType: string;
usdtAmount: number;
hashpowerAmount: number;
createdAt: string;
expiredAt: string | null;
}
// [2026-01-07] 新增:过期收益明细响应
/**
*
*/
export interface ExpiredRewardsEntriesResponse {
entries: ExpiredRewardEntryDTO[];
total: number;
page: number;
pageSize: number;
totalPages: number;
}
/**
*
*/