44 KiB
getHomeData 首页数据统计完整逻辑文档
文档信息
- 文件路径:
app/api/home/home.ts - 主函数:
getHomeData() - 创建日期: 2025-01-17
- 文档版本: 2.1
- 更新说明: 改用 typeIds 参数直接指定文档类型 ID
目录
函数概述
getHomeData() 是首页数据统计的核心函数,用于获取以下 6 项关键业务指标:
- 今日待审核文件数 - 统计今天创建的待审核文件
- 本月已审核文件数 - 统计本月完成审核的文件总数
- 审核文件同比增长 - 与上月对比的增长百分比
- 本月审核通过率 - 本月通过文件数 / 本月已审核文件数
- 通过率同比增长 - 与上月通过率对比的增长百分比
- 检测到的问题总数 - 从评估结果中统计问题数量
- 问题数量同比增长 - 与上月问题数量对比的增长百分比
函数签名与返回值
函数签名
export async function getHomeData(
typeIds?: number[] | number | null, // 文档类型 ID 数组或单个 ID
userId?: string | number, // 用户 ID
token?: string // JWT Token
): Promise<HomeStatistics>
参数说明
| 参数 | 类型 | 必填 | 说明 |
|---|---|---|---|
typeIds |
number[] | number | null |
否 | 文档类型 ID 筛选: - 单个 ID: 1 (筛选 type_id = 1)- ID 数组: [2, 3] (筛选 type_id = 2 或 3)- null 或 undefined: 不筛选类型 |
userId |
string | number |
是 | 当前用户的 ID,用于过滤用户数据 |
token |
string |
是 | JWT 认证令牌 |
返回值结构
interface HomeStatistics {
todayPendingFiles: number; // 今日待审核文件数
monthlyReviewedFiles: number; // 本月已审核文件数
monthlyReviewGrowth: { // 本月审核文件同比增长
value: number; // 增长百分比(绝对值)
isUp: boolean; // 是否增长(true: 增长, false: 下降)
};
monthlyPassRate: number; // 本月审核通过率(百分比)
passRateGrowth: { // 通过率同比增长
value: number;
isUp: boolean;
};
issuesDetected: number; // 检测到的问题总数
issuesGrowth: { // 问题数量同比增长
value: number;
isUp: boolean;
};
}
返回示例
{
"todayPendingFiles": 15,
"monthlyReviewedFiles": 120,
"monthlyReviewGrowth": {
"value": 25.5,
"isUp": true
},
"monthlyPassRate": 85.3,
"passRateGrowth": {
"value": 3.2,
"isUp": true
},
"issuesDetected": 42,
"issuesGrowth": {
"value": 12.8,
"isUp": false
}
}
业务逻辑流程
主流程图
开始
│
├─→ 1. 计算时间范围
│ ├─ 今天开始时间 (startOfToday)
│ ├─ 本月开始/结束 (startOfThisMonth, endOfThisMonth)
│ └─ 上月开始/结束 (startOfLastMonth, endOfLastMonth)
│
├─→ 2. 构建类型过滤条件
│ ├─ 单个 ID (如 1) → type_id.eq.1
│ ├─ ID 数组 (如 [2,3]) → (type_id.eq.2,type_id.eq.3)
│ └─ null/undefined → 不过滤
│
├─→ 3. 查询今日待审核文件数
│ └─ 条件: audit_status IN (0,2,NULL) AND created_at >= 今天开始
│
├─→ 4. 查询本月已审核文件数
│ └─ 条件: audit_status NOT IN (0,2) AND upload_time >= 本月开始
│
├─→ 5. 查询上月已审核文件数
│ └─ 条件: audit_status NOT IN (0,2) AND upload_time BETWEEN 上月开始 AND 上月结束
│
├─→ 6. 计算审核文件同比增长
│ └─ 增长率 = (本月数量 - 上月数量) / 上月数量 * 100
│
├─→ 7. 查询本月审核通过数量
│ └─ 条件: audit_status = 1 AND created_at >= 本月开始
│
├─→ 8. 计算本月审核通过率
│ └─ 通过率 = 本月通过数量 / 本月已审核数量 * 100
│
├─→ 9. 查询上月审核通过数量
│ └─ 条件: audit_status = 1 AND upload_time BETWEEN 上月开始 AND 上月结束
│
├─→ 10. 计算上月审核通过率
│ └─ 通过率 = 上月通过数量 / 上月已审核数量 * 100
│
├─→ 11. 计算通过率同比增长
│ └─ 增长率 = (本月通过率 - 上月通过率) / 上月通过率 * 100
│
├─→ 12. 调用数据库函数统计问题数量
│ ├─ 本月问题数量 (RPC: count_evaluation_results_by_type)
│ └─ 上月问题数量 (RPC: count_evaluation_results_by_type)
│
├─→ 13. 计算问题数量同比增长
│ └─ 增长率 = (本月问题数 - 上月问题数) / 上月问题数 * 100
│
└─→ 14. 返回统计结果
└─ HomeStatistics 对象
时间范围计算
使用 dayjs 库进行时间计算,确保时区一致性。
代码实现
import dayjs from 'dayjs';
// 今天开始时间 (00:00:00)
const startOfToday = dayjs().startOf('day').format('YYYY-MM-DD HH:mm:ss');
// 示例: '2025-01-17 00:00:00'
// 本月开始时间 (月初 00:00:00)
const startOfThisMonth = dayjs().startOf('month').format('YYYY-MM-DD HH:mm:ss');
// 示例: '2025-01-01 00:00:00'
// 本月结束时间 (月末 23:59:59)
const endOfThisMonth = dayjs().endOf('month').format('YYYY-MM-DD HH:mm:ss');
// 示例: '2025-01-31 23:59:59'
// 上月开始时间
const startOfLastMonth = dayjs().subtract(1, 'month').startOf('month').format('YYYY-MM-DD HH:mm:ss');
// 示例: '2024-12-01 00:00:00'
// 上月结束时间
const endOfLastMonth = dayjs().subtract(1, 'month').endOf('month').format('YYYY-MM-DD HH:mm:ss');
// 示例: '2024-12-31 23:59:59'
时间范围示例表
假设当前时间为 2025-01-17 15:30:45
| 时间变量 | 值 | 说明 |
|---|---|---|
startOfToday |
2025-01-17 00:00:00 |
今天凌晨 |
startOfThisMonth |
2025-01-01 00:00:00 |
本月第一天凌晨 |
endOfThisMonth |
2025-01-31 23:59:59 |
本月最后一天 23:59:59 |
startOfLastMonth |
2024-12-01 00:00:00 |
上月第一天凌晨 |
endOfLastMonth |
2024-12-31 23:59:59 |
上月最后一天 23:59:59 |
数据查询详解
辅助函数:buildTypeFilter()
功能: 根据 typeIds 参数构建 PostgREST 类型过滤条件
function buildTypeFilter(typeIds?: number[] | number | null): string {
if (!typeIds) {
return ''; // null 或 undefined 返回空字符串,表示不过滤
}
// 标准化为数组
const idsArray = Array.isArray(typeIds) ? typeIds : [typeIds];
if (idsArray.length === 0) {
return '';
}
if (idsArray.length === 1) {
// 单个 ID: type_id.eq.1
return `type_id.eq.${idsArray[0]}`;
} else {
// 多个 ID: (type_id.eq.2,type_id.eq.3)
return `(${idsArray.map(id => `type_id.eq.${id}`).join(',')})`;
}
}
参数说明:
typeIds: 可以是单个数字、数字数组或 null/undefined
返回值说明:
1→'type_id.eq.1'[2, 3]→'(type_id.eq.2,type_id.eq.3)'null/undefined→''(空字符串)
使用示例:
buildTypeFilter(1); // 'type_id.eq.1'
buildTypeFilter([2, 3]); // '(type_id.eq.2,type_id.eq.3)'
buildTypeFilter([1, 2, 3]); // '(type_id.eq.1,type_id.eq.2,type_id.eq.3)'
buildTypeFilter(null); // ''
buildTypeFilter(undefined); // ''
查询 1: 今日待审核文件数
业务逻辑: 统计今天创建的、状态为待审核的文件数量
审核状态说明:
audit_status = 0: 未审核audit_status = 2: 审核中audit_status = NULL: 未设置状态
PostgREST 参数:
const todayPendingParams: PostgrestParams = {
select: 'count',
filter: {
or: `(audit_status.eq.0,audit_status.eq.2,audit_status.is.null)`,
created_at: `gte.${startOfToday}`,
is_test_document: `eq.false`,
user_id: `eq.${userId}`
}
};
// 如果有类型过滤
if (typeFilter) {
if (typeFilter.startsWith('(')) {
// OR 条件: (type_id.eq.2,type_id.eq.3)
todayPendingParams.filter.or = typeFilter + ',' + todayPendingParams.filter.or;
} else {
// 单一条件: type_id.eq.1
const [field, op, value] = typeFilter.split('.');
todayPendingParams.filter[field] = `${op}.${value}`;
}
}
等价 SQL 查询:
SELECT COUNT(*)
FROM documents
WHERE (audit_status = 0 OR audit_status = 2 OR audit_status IS NULL)
AND created_at >= '2025-01-17 00:00:00'
AND is_test_document = false
AND user_id = 123
AND type_id = 1; -- 如果 typeIds = 1
-- 或 (type_id = 2 OR type_id = 3); -- 如果 typeIds = [2, 3]
实际 API 调用:
const todayPendingCount = await postgrestGet('documents', {
...todayPendingParams,
token
});
// 返回格式: [{ count: 15 }]
const todayPendingFiles = todayPendingCount[0]?.count || 0;
查询 2: 本月已审核文件数
业务逻辑: 统计本月上传的、已完成审核的文件数量(排除待审核和审核中)
PostgREST 参数:
const thisMonthReviewedParams: PostgrestParams = {
select: 'count',
filter: {
and: `(audit_status.neq.0,audit_status.neq.2)`,
upload_time: `gte.${startOfThisMonth}`,
is_test_document: `eq.false`,
user_id: `eq.${userId}`
}
};
// 添加类型过滤条件
if (typeFilter) {
if (typeFilter.startsWith('(')) {
thisMonthReviewedParams.or = typeFilter;
} else {
const [field, op, value] = typeFilter.split('.');
thisMonthReviewedParams.filter[field] = `${op}.${value}`;
}
}
等价 SQL 查询:
SELECT COUNT(*)
FROM documents
WHERE audit_status != 0
AND audit_status != 2
AND upload_time >= '2025-01-01 00:00:00'
AND is_test_document = false
AND user_id = 123
AND type_id = 1; -- 根据 typeIds 过滤
实际 API 调用:
const thisMonthReviewedCount = await postgrestGet('documents', {
...thisMonthReviewedParams,
token
});
// 返回格式: [{ count: 120 }]
const monthlyReviewedFiles = thisMonthReviewedCount[0]?.count || 0;
查询 3: 上月已审核文件数
业务逻辑: 统计上月的已审核文件数量,用于计算同比增长
PostgREST 参数:
const lastMonthReviewedParams: PostgrestParams = {
select: 'count',
filter: {
and: `(upload_time.gte.${startOfLastMonth},upload_time.lte.${endOfLastMonth},audit_status.neq.0,audit_status.neq.2)`,
is_test_document: `eq.false`,
user_id: `eq.${userId}`
}
};
// 添加类型过滤条件
if (typeFilter) {
if (typeFilter.startsWith('(')) {
lastMonthReviewedParams.filter.or = typeFilter;
} else {
const [field, op, value] = typeFilter.split('.');
lastMonthReviewedParams.filter[field] = `${op}.${value}`;
}
}
等价 SQL 查询:
SELECT COUNT(*)
FROM documents
WHERE upload_time >= '2024-12-01 00:00:00'
AND upload_time <= '2024-12-31 23:59:59'
AND audit_status != 0
AND audit_status != 2
AND is_test_document = false
AND user_id = 123
AND type_id = 1;
实际 API 调用:
const lastMonthReviewedCount = await postgrestGet('documents', {
...lastMonthReviewedParams,
token
});
// 返回格式: [{ count: 96 }]
const lastMonthReviewed = lastMonthReviewedCount[0]?.count || 0;
查询 4: 本月审核通过数量
业务逻辑: 统计本月审核通过的文件数量(audit_status = 1)
PostgREST 参数:
const thisMonthTotalParams: PostgrestParams = {
select: 'count',
filter: {
audit_status: `eq.1`,
created_at: `gte.${startOfThisMonth}`,
is_test_document: `eq.false`,
user_id: `eq.${userId}`
}
};
// 添加类型过滤条件
if (typeFilter) {
if (typeFilter.startsWith('(')) {
thisMonthTotalParams.or = typeFilter;
} else {
const [field, op, value] = typeFilter.split('.');
thisMonthTotalParams.filter[field] = `${op}.${value}`;
}
}
等价 SQL 查询:
SELECT COUNT(*)
FROM documents
WHERE audit_status = 1 -- 审核通过
AND created_at >= '2025-01-01 00:00:00'
AND is_test_document = false
AND user_id = 123
AND type_id = 1;
实际 API 调用:
const thisMonthTotalCount = await postgrestGet('documents', {
...thisMonthTotalParams,
token
});
// 返回格式: [{ count: 102 }]
const thisMonthPassTotal = thisMonthTotalCount[0]?.count || 0;
查询 5: 上月审核通过数量
业务逻辑: 统计上月审核通过的文件数量,用于计算通过率同比增长
PostgREST 参数:
const lastMonthTotalParams: PostgrestParams = {
select: 'count',
filter: {
audit_status: `eq.1`,
and: `(upload_time.gte.${startOfLastMonth},upload_time.lte.${endOfLastMonth})`,
is_test_document: `eq.false`,
user_id: `eq.${userId}`
}
};
// 添加类型过滤条件
if (typeFilter) {
if (typeFilter.startsWith('(')) {
lastMonthTotalParams.or = typeFilter;
} else {
const [field, op, value] = typeFilter.split('.');
lastMonthTotalParams.filter[field] = `${op}.${value}`;
}
}
等价 SQL 查询:
SELECT COUNT(*)
FROM documents
WHERE audit_status = 1
AND upload_time >= '2024-12-01 00:00:00'
AND upload_time <= '2024-12-31 23:59:59'
AND is_test_document = false
AND user_id = 123
AND type_id = 1;
实际 API 调用:
const lastMonthTotalCount = await postgrestGet('documents', {
...lastMonthTotalParams,
token
});
// 返回格式: [{ count: 78 }]
const lastMonthTotal = lastMonthTotalCount[0]?.count || 0;
数据库例程详解
count_evaluation_results_by_type 函数
这是一个 PostgreSQL 存储函数(PL/pgSQL),用于统计特定时间范围内、特定文档类型的评估问题数量。
函数定义
CREATE OR REPLACE FUNCTION public.count_evaluation_results_by_type(
start_time TIMESTAMP WITHOUT TIME ZONE,
end_time TIMESTAMP WITHOUT TIME ZONE,
type_val INTEGER[] DEFAULT NULL,
userid INTEGER DEFAULT NULL
) RETURNS TABLE(count BIGINT)
LANGUAGE plpgsql
AS $function$
BEGIN
RETURN QUERY
SELECT COUNT(*)
FROM evaluation_results er
JOIN documents d ON er.document_id = d.id
WHERE (type_val IS NULL OR d.type_id = ANY(type_val))
AND (er.evaluated_results ->> 'result')::text = 'false'
AND er.created_at >= start_time
AND er.created_at <= end_time
AND (userid IS NULL OR d.user_id = userid);
END;
$function$
参数说明
| 参数 | 类型 | 默认值 | 说明 |
|---|---|---|---|
start_time |
TIMESTAMP |
必填 | 统计开始时间 |
end_time |
TIMESTAMP |
必填 | 统计结束时间 |
type_val |
INTEGER[] |
NULL |
文档类型 ID 数组,NULL 表示不筛选类型 |
userid |
INTEGER |
NULL |
用户 ID,NULL 表示不筛选用户 |
返回值
返回一个表,包含单列 count(BIGINT 类型),表示符合条件的问题数量。
业务逻辑
- 关联查询: 从
evaluation_results表关联documents表 - 类型筛选: 如果
type_val不为NULL,则筛选指定类型的文档 - 问题筛选: 只统计
evaluated_results.result = 'false'的记录(表示有问题) - 时间范围: 筛选
er.created_at在指定时间范围内的记录 - 用户筛选: 如果
userid不为NULL,则筛选指定用户的文档
关键逻辑
判断评估结果是否为问题:
(er.evaluated_results ->> 'result')::text = 'false'
evaluated_results是 JSONB 类型字段->>操作符提取 JSON 字段的文本值- 当
result字段为'false'时,表示该评估点检查未通过(有问题)
类型筛选的灵活性:
(type_val IS NULL OR d.type_id = ANY(type_val))
- 如果
type_val为NULL,则不筛选类型 - 否则使用
ANY(type_val)匹配数组中的任意值
使用示例
示例 1: 查询合同类型的问题数量
const response = await postgrestPost<{ count: number }[]>(
'rpc/count_evaluation_results_by_type',
{
start_time: '2025-01-01 00:00:00',
end_time: '2025-01-31 23:59:59',
type_val: [1], // 合同类型 ID
userid: 123
},
token
);
// 返回: [{ count: 42 }]
const issuesCount = response[0]?.count || 0;
示例 2: 查询记录类型(多个类型)的问题数量
const response = await postgrestPost<{ count: number }[]>(
'rpc/count_evaluation_results_by_type',
{
start_time: '2024-12-01 00:00:00',
end_time: '2024-12-31 23:59:59',
type_val: [2, 3], // 记录类型 ID(类型2和类型3)
userid: 123
},
token
);
// 返回: [{ count: 35 }]
const lastMonthIssues = response[0]?.count || 0;
示例 3: 查询所有类型的问题数量(不筛选用户)
const response = await postgrestPost<{ count: number }[]>(
'rpc/count_evaluation_results_by_type',
{
start_time: '2025-01-01 00:00:00',
end_time: '2025-01-31 23:59:59',
type_val: null, // 所有类型
userid: null // 所有用户
},
token
);
// 返回: [{ count: 150 }]
实际调用代码(在 getHomeData 中)
示例 1: 查询单个类型(type_id = 1):
// typeIds = 1 或 [1]
const typeToQuery = Array.isArray(typeIds) ? typeIds : [typeIds];
// 本月问题数量
const thisMonthIssuesResponse = await handleApiResponse<{ count: number }[]>(
postgrestPost('rpc/count_evaluation_results_by_type', {
start_time: startOfThisMonth,
end_time: endOfThisMonth,
type_val: typeToQuery, // [1]
userid: parseInt(userId as string)
}, token),
'获取本月问题数据失败',
[]
);
thisMonthIssuesCount = thisMonthIssuesResponse[0]?.count || 0;
// 上月问题数量
const lastMonthIssuesResponse = await handleApiResponse<{ count: number }[]>(
postgrestPost('rpc/count_evaluation_results_by_type', {
start_time: startOfLastMonth,
end_time: endOfLastMonth,
type_val: typeToQuery, // [1]
userid: parseInt(userId as string)
}, token),
'获取上月问题数据失败',
[]
);
lastMonthIssuesCount = lastMonthIssuesResponse[0]?.count || 0;
示例 2: 查询多个类型(type_id = 2 或 3):
// typeIds = [2, 3]
const typeToQuery = typeIds; // [2, 3]
// 本月问题数量(类型2和3合并统计)
const thisMonthIssuesResponse = await handleApiResponse<{ count: number }[]>(
postgrestPost('rpc/count_evaluation_results_by_type', {
start_time: startOfThisMonth,
end_time: endOfThisMonth,
type_val: typeToQuery, // [2, 3]
userid: parseInt(userId as string)
}, token),
'获取本月问题数据失败',
[]
);
thisMonthIssuesCount = thisMonthIssuesResponse[0]?.count || 0;
// 上月问题数量
const lastMonthIssuesResponse = await handleApiResponse<{ count: number }[]>(
postgrestPost('rpc/count_evaluation_results_by_type', {
start_time: startOfLastMonth,
end_time: endOfLastMonth,
type_val: typeToQuery, // [2, 3]
userid: parseInt(userId as string)
}, token),
'获取上月问题数据失败',
[]
);
lastMonthIssuesCount = lastMonthIssuesResponse[0]?.count || 0;
示例 3: 查询所有类型(不筛选):
// typeIds = null 或 undefined
const typeToQuery = null;
// 本月问题数量(所有类型)
const thisMonthIssuesResponse = await handleApiResponse<{ count: number }[]>(
postgrestPost('rpc/count_evaluation_results_by_type', {
start_time: startOfThisMonth,
end_time: endOfThisMonth,
type_val: typeToQuery, // null
userid: parseInt(userId as string)
}, token),
'获取本月问题数据失败',
[]
);
thisMonthIssuesCount = thisMonthIssuesResponse[0]?.count || 0;
性能优化建议
索引优化:
-- 评估结果表索引
CREATE INDEX idx_eval_results_created_at ON evaluation_results(created_at);
CREATE INDEX idx_eval_results_document_id ON evaluation_results(document_id);
CREATE INDEX idx_eval_results_result ON evaluation_results((evaluated_results->>'result'));
-- 文档表索引
CREATE INDEX idx_documents_type_user ON documents(type_id, user_id);
CREATE INDEX idx_documents_user_id ON documents(user_id);
-- 复合索引(覆盖查询)
CREATE INDEX idx_eval_doc_composite ON evaluation_results(document_id, created_at)
INCLUDE ((evaluated_results->>'result'));
查询计划分析:
EXPLAIN ANALYZE
SELECT COUNT(*)
FROM evaluation_results er
JOIN documents d ON er.document_id = d.id
WHERE d.type_id = ANY(ARRAY[1])
AND (er.evaluated_results ->> 'result')::text = 'false'
AND er.created_at >= '2025-01-01 00:00:00'
AND er.created_at <= '2025-01-31 23:59:59'
AND d.user_id = 123;
增长率计算公式
审核文件同比增长
计算逻辑:
let reviewGrowthValue = 0;
let reviewGrowthIsUp = true;
if (lastMonthReviewed > 0) {
// 正常计算增长率
const growthRate = ((monthlyReviewedFiles - lastMonthReviewed) / lastMonthReviewed) * 100;
reviewGrowthValue = Math.abs(parseFloat(growthRate.toFixed(1)));
reviewGrowthIsUp = growthRate >= 0;
} else if (lastMonthReviewed == 0 && monthlyReviewedFiles > 0) {
// 特殊情况:上月为0,本月大于0
reviewGrowthValue = 100;
reviewGrowthIsUp = true;
}
公式:
增长率 = (本月数量 - 上月数量) / 上月数量 × 100%
计算示例:
| 本月数量 | 上月数量 | 增长率 | value | isUp |
|---|---|---|---|---|
| 120 | 96 | (120-96)/96 × 100 = 25% | 25.0 | true |
| 80 | 100 | (80-100)/100 × 100 = -20% | 20.0 | false |
| 100 | 100 | (100-100)/100 × 100 = 0% | 0.0 | true |
| 50 | 0 | 特殊处理 | 100.0 | true |
| 0 | 0 | 不计算 | 0.0 | true |
注意事项:
value始终为绝对值(非负数)isUp表示增长方向(true: 增长或持平, false: 下降)- 上月为 0 时,固定返回 100% 增长
审核通过率计算
本月通过率:
const monthlyPassRate = (thisMonthPassTotal > 0 && monthlyReviewedFiles > 0)
? parseFloat(((thisMonthPassTotal / monthlyReviewedFiles) * 100).toFixed(1))
: 0;
公式:
通过率 = 本月审核通过数量 / 本月已审核数量 × 100%
计算示例:
| 审核通过数量 | 已审核数量 | 通过率 |
|---|---|---|
| 102 | 120 | 102/120 × 100 = 85.0% |
| 78 | 96 | 78/96 × 100 = 81.3% |
| 0 | 100 | 0% |
| 50 | 0 | 0% (防止除0错误) |
通过率同比增长
计算逻辑:
let passRateGrowthValue = 0;
let passRateGrowthIsUp = true;
if (lastMonthPassRate > 0) {
const passRateGrowth = ((monthlyPassRate - lastMonthPassRate) / lastMonthPassRate) * 100;
passRateGrowthValue = Math.abs(parseFloat(passRateGrowth.toFixed(1)));
passRateGrowthIsUp = passRateGrowth >= 0;
} else if (lastMonthPassRate == 0 && monthlyPassRate > 0) {
passRateGrowthValue = 100;
passRateGrowthIsUp = true;
}
公式:
通过率增长率 = (本月通过率 - 上月通过率) / 上月通过率 × 100%
计算示例:
| 本月通过率 | 上月通过率 | 增长率 | value | isUp |
|---|---|---|---|---|
| 85.0% | 81.3% | (85.0-81.3)/81.3 × 100 = 4.6% | 4.6 | true |
| 80.0% | 85.0% | (80.0-85.0)/85.0 × 100 = -5.9% | 5.9 | false |
| 85.0% | 0% | 特殊处理 | 100.0 | true |
问题数量同比增长
计算逻辑:
let issuesGrowthValue = 0;
let issuesGrowthIsUp = true;
if (lastMonthIssuesCount > 0) {
const issuesGrowth = ((thisMonthIssuesCount - lastMonthIssuesCount) / lastMonthIssuesCount) * 100;
issuesGrowthValue = Math.abs(parseFloat(issuesGrowth.toFixed(1)));
issuesGrowthIsUp = issuesGrowth >= 0;
} else if (lastMonthIssuesCount == 0 && thisMonthIssuesCount > 0) {
issuesGrowthValue = 100;
issuesGrowthIsUp = true;
}
公式:
问题增长率 = (本月问题数 - 上月问题数) / 上月问题数 × 100%
计算示例:
| 本月问题数 | 上月问题数 | 增长率 | value | isUp |
|---|---|---|---|---|
| 42 | 35 | (42-35)/35 × 100 = 20.0% | 20.0 | true |
| 30 | 42 | (30-42)/42 × 100 = -28.6% | 28.6 | false |
| 50 | 0 | 特殊处理 | 100.0 | true |
注意: 对于问题数量,isUp = true 表示问题增多(通常是负面指标)
PostgREST 请求示例
完整请求流程示例
假设:
- 用户 ID:
123 - 文档类型 ID:
1(合同类型) - 当前时间:
2025-01-17
请求 1: 今日待审核文件数
HTTP 请求:
GET /documents?select=count&or=(audit_status.eq.0,audit_status.eq.2,audit_status.is.null,type_id.eq.1)&created_at=gte.2025-01-17%2000:00:00&is_test_document=eq.false&user_id=eq.123
Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...
响应:
[
{ "count": 15 }
]
请求 2: 本月已审核文件数
HTTP 请求:
GET /documents?select=count&and=(audit_status.neq.0,audit_status.neq.2)&upload_time=gte.2025-01-01%2000:00:00&is_test_document=eq.false&user_id=eq.123&type_id=eq.1
Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...
响应:
[
{ "count": 120 }
]
请求 3: 上月已审核文件数
HTTP 请求:
GET /documents?select=count&and=(upload_time.gte.2024-12-01%2000:00:00,upload_time.lte.2024-12-31%2023:59:59,audit_status.neq.0,audit_status.neq.2)&is_test_document=eq.false&user_id=eq.123&type_id=eq.1
Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...
响应:
[
{ "count": 96 }
]
请求 4: 本月审核通过数量
HTTP 请求:
GET /documents?select=count&audit_status=eq.1&created_at=gte.2025-01-01%2000:00:00&is_test_document=eq.false&user_id=eq.123&type_id=eq.1
Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...
响应:
[
{ "count": 102 }
]
请求 5: 本月问题数量(RPC 调用)
HTTP 请求:
POST /rpc/count_evaluation_results_by_type
Content-Type: application/json
Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...
{
"start_time": "2025-01-01 00:00:00",
"end_time": "2025-01-31 23:59:59",
"type_val": [1],
"userid": 123
}
响应:
[
{ "count": 42 }
]
完整代码流程图
getHomeData(typeIds=1, userId=123, token='...')
│
├─→ [1] 时间计算
│ ├─ startOfToday = '2025-01-17 00:00:00'
│ ├─ startOfThisMonth = '2025-01-01 00:00:00'
│ ├─ endOfThisMonth = '2025-01-31 23:59:59'
│ ├─ startOfLastMonth = '2024-12-01 00:00:00'
│ └─ endOfLastMonth = '2024-12-31 23:59:59'
│
├─→ [2] 类型过滤
│ └─ typeFilter = 'type_id.eq.1' (单个 ID: 1)
│
├─→ [3] 今日待审核文件数
│ ├─ API: GET /documents?select=count&...
│ ├─ 条件: audit_status IN (0,2,NULL), created_at >= '2025-01-17'
│ └─ 结果: todayPendingFiles = 15
│
├─→ [4] 本月已审核文件数
│ ├─ API: GET /documents?select=count&...
│ ├─ 条件: audit_status NOT IN (0,2), upload_time >= '2025-01-01'
│ └─ 结果: monthlyReviewedFiles = 120
│
├─→ [5] 上月已审核文件数
│ ├─ API: GET /documents?select=count&...
│ ├─ 条件: audit_status NOT IN (0,2), upload_time BETWEEN '2024-12-01' AND '2024-12-31'
│ └─ 结果: lastMonthReviewed = 96
│
├─→ [6] 计算审核文件增长率
│ ├─ growthRate = (120 - 96) / 96 * 100 = 25%
│ └─ 结果: { value: 25.0, isUp: true }
│
├─→ [7] 本月审核通过数量
│ ├─ API: GET /documents?select=count&...
│ ├─ 条件: audit_status = 1, created_at >= '2025-01-01'
│ └─ 结果: thisMonthPassTotal = 102
│
├─→ [8] 计算本月通过率
│ ├─ monthlyPassRate = 102 / 120 * 100 = 85.0%
│ └─ 结果: 85.0
│
├─→ [9] 上月审核通过数量
│ ├─ API: GET /documents?select=count&...
│ ├─ 条件: audit_status = 1, upload_time BETWEEN '2024-12-01' AND '2024-12-31'
│ └─ 结果: lastMonthTotal = 78
│
├─→ [10] 计算上月通过率
│ ├─ lastMonthPassRate = 78 / 96 * 100 = 81.3%
│ └─ 结果: 81.3
│
├─→ [11] 计算通过率增长
│ ├─ passRateGrowth = (85.0 - 81.3) / 81.3 * 100 = 4.6%
│ └─ 结果: { value: 4.6, isUp: true }
│
├─→ [12] 本月问题数量
│ ├─ API: POST /rpc/count_evaluation_results_by_type
│ ├─ 参数: start_time='2025-01-01', end_time='2025-01-31', type_val=[1], userid=123
│ └─ 结果: thisMonthIssuesCount = 42
│
├─→ [13] 上月问题数量
│ ├─ API: POST /rpc/count_evaluation_results_by_type
│ ├─ 参数: start_time='2024-12-01', end_time='2024-12-31', type_val=[1], userid=123
│ └─ 结果: lastMonthIssuesCount = 35
│
├─→ [14] 计算问题数量增长
│ ├─ issuesGrowth = (42 - 35) / 35 * 100 = 20.0%
│ └─ 结果: { value: 20.0, isUp: true }
│
└─→ [15] 返回结果
└─ {
todayPendingFiles: 15,
monthlyReviewedFiles: 120,
monthlyReviewGrowth: { value: 25.0, isUp: true },
monthlyPassRate: 85.0,
passRateGrowth: { value: 4.6, isUp: true },
issuesDetected: 42,
issuesGrowth: { value: 20.0, isUp: true }
}
错误处理机制
辅助函数:handleApiResponse()
功能: 统一处理 API 调用的错误和默认值
const handleApiResponse = async <T>(
apiCall: Promise<{
data?: unknown;
headers?: Record<string, string>;
error?: string;
status?: number
}>,
errorMessage: string,
defaultValue: T
): Promise<T> => {
try {
const response = await apiCall;
if (response.error) {
console.error(`${errorMessage}: ${response.error}`);
return defaultValue;
}
const data = extractApiData<T>(response.data);
if (!data) {
console.warn(`${errorMessage}: 无法提取有效数据`);
return defaultValue;
}
return data;
} catch (error) {
console.error(`${errorMessage}: ${error instanceof Error ? error.message : '未知错误'}`);
return defaultValue;
}
};
使用示例:
const todayPendingCount = await handleApiResponse<{ count: number }[]>(
postgrestGet('documents', { ...todayPendingParams, token }),
'获取今日待审核文件数量失败',
[] // 默认返回空数组
);
全局错误处理
外层 try-catch:
try {
// 所有业务逻辑
...
} catch (error) {
console.error('获取首页数据失败:', error instanceof Error ? error.message : String(error));
// 返回默认值以防止页面崩溃
return {
todayPendingFiles: 0,
monthlyReviewedFiles: 0,
monthlyReviewGrowth: { value: 0, isUp: true },
monthlyPassRate: 0,
passRateGrowth: { value: 0, isUp: true },
issuesDetected: 0,
issuesGrowth: { value: 0, isUp: true }
};
}
错误处理策略:
- 单个查询失败: 返回默认值(0 或空数组),记录错误日志,不中断整体流程
- 整体失败: 返回完整的默认数据结构,确保页面可以正常渲染
数据提取函数:extractApiData()
功能: 从不同格式的 API 响应中提取数据
function extractApiData<T>(responseData: unknown): T | null {
if (!responseData) {
console.warn('API响应数据为空');
return null;
}
try {
// 检查错误码
if (typeof responseData === 'object' && responseData !== null) {
if ('code' in responseData) {
const code = (responseData as { code: number }).code;
if (code !== 0 && code !== 200) {
const errorMsg = 'msg' in responseData
? (responseData as { msg: string }).msg
: '未知错误';
console.error(`API响应错误: [${code}] ${errorMsg}`);
return null;
}
}
// 检查错误消息
if ('error' in responseData && (responseData as { error: unknown }).error) {
const error = (responseData as { error: unknown }).error;
console.error(`API响应包含错误: ${typeof error === 'string' ? error : JSON.stringify(error)}`);
return null;
}
// 格式1: { code: number, msg: string, data: T }
if ('data' in responseData) {
const data = (responseData as { data: unknown }).data;
if (!data) {
console.warn('API响应中的data字段为空');
return null;
}
return data as T;
}
}
// 格式2: 直接是数据对象
return responseData as T;
} catch (error) {
console.error('处理API响应数据时出错:', error);
return null;
}
}
支持的响应格式:
- 格式1 (9000端口):
{
"code": 0,
"msg": "success",
"data": [{ "count": 10 }]
}
- 格式2 (8000端口):
[{ "count": 10 }]
- 错误响应:
{
"code": 500,
"msg": "Internal Server Error"
}
性能优化建议
1. 并行查询
当前实现: 串行查询(一个接一个)
优化方案: 使用 Promise.all 并行执行所有查询
// ✅ 推荐:并行查询
const [
todayPendingCount,
thisMonthReviewedCount,
lastMonthReviewedCount,
thisMonthPassCount,
lastMonthPassCount,
thisMonthIssuesResponse,
lastMonthIssuesResponse
] = await Promise.all([
postgrestGet('documents', { ...todayPendingParams, token }),
postgrestGet('documents', { ...thisMonthReviewedParams, token }),
postgrestGet('documents', { ...lastMonthReviewedParams, token }),
postgrestGet('documents', { ...thisMonthTotalParams, token }),
postgrestGet('documents', { ...lastMonthTotalParams, token }),
postgrestPost('rpc/count_evaluation_results_by_type', thisMonthIssuesParams, token),
postgrestPost('rpc/count_evaluation_results_by_type', lastMonthIssuesParams, token)
]);
// 性能提升:从 ~700ms (7 × 100ms) 降低到 ~100ms
2. Redis 缓存
缓存策略: 缓存 5 分钟(统计数据不需要实时更新)
import Redis from 'ioredis';
const redis = new Redis();
export async function getHomeData(typeIds, userId, token) {
// 生成缓存键
const date = dayjs().format('YYYY-MM-DD');
// 将 typeIds 转换为字符串作为键的一部分
const typeKey = typeIds
? Array.isArray(typeIds)
? typeIds.join(',')
: String(typeIds)
: 'all';
const cacheKey = `home:stats:${userId}:${typeKey}:${date}`;
// 尝试从缓存获取
const cached = await redis.get(cacheKey);
if (cached) {
console.log('✅ 从缓存返回首页数据');
return JSON.parse(cached);
}
// 查询数据库
const result = await fetchHomeDataFromDB(typeIds, userId, token);
// 缓存 5 分钟
await redis.setex(cacheKey, 300, JSON.stringify(result));
return result;
}
缓存失效策略:
- 每天零点自动失效(键包含日期)
- 用户上传新文档后手动清除缓存
- TTL 设置为 5 分钟
3. 数据库索引优化
documents 表索引:
-- 复合索引:覆盖常用查询
CREATE INDEX idx_documents_user_status_time ON documents(user_id, audit_status, created_at);
CREATE INDEX idx_documents_user_type_upload ON documents(user_id, type_id, upload_time);
CREATE INDEX idx_documents_user_created ON documents(user_id, created_at) WHERE is_test_document = false;
-- 条件索引:仅索引非测试文档
CREATE INDEX idx_documents_non_test ON documents(user_id, audit_status) WHERE is_test_document = false;
evaluation_results 表索引:
-- 支持 count_evaluation_results_by_type 函数
CREATE INDEX idx_eval_results_time_result ON evaluation_results(created_at, (evaluated_results->>'result'));
CREATE INDEX idx_eval_results_doc_time ON evaluation_results(document_id, created_at);
4. 物化视图(高级优化)
创建物化视图: 预计算每日统计数据
CREATE MATERIALIZED VIEW mv_daily_document_stats AS
SELECT
user_id,
type_id,
DATE(created_at) as stat_date,
COUNT(*) FILTER (WHERE audit_status IN (0, 2)) as pending_count,
COUNT(*) FILTER (WHERE audit_status NOT IN (0, 2)) as reviewed_count,
COUNT(*) FILTER (WHERE audit_status = 1) as passed_count
FROM documents
WHERE is_test_document = false
GROUP BY user_id, type_id, DATE(created_at);
-- 创建索引
CREATE INDEX ON mv_daily_document_stats(user_id, type_id, stat_date);
-- 定时刷新(每小时)
REFRESH MATERIALIZED VIEW CONCURRENTLY mv_daily_document_stats;
使用物化视图查询:
// 查询本月已审核文件数
const response = await postgrestGet('mv_daily_document_stats', {
select: 'sum(reviewed_count)',
filter: {
'user_id': `eq.${userId}`,
'type_id': `eq.1`,
'stat_date': `gte.${startOfThisMonth.split(' ')[0]}`
}
});
性能提升: 从全表扫描降低到索引查询,查询时间从 ~100ms 降低到 ~10ms
5. 数据库函数优化
优化 count_evaluation_results_by_type 函数:
-- 添加结果缓存(需要 PostgreSQL 12+)
CREATE OR REPLACE FUNCTION count_evaluation_results_by_type(...)
RETURNS TABLE(count BIGINT)
LANGUAGE plpgsql
STABLE -- 标记为稳定函数,允许查询计划缓存
AS $function$
BEGIN
RETURN QUERY
SELECT COUNT(*)
FROM evaluation_results er
INNER JOIN documents d ON er.document_id = d.id -- 使用 INNER JOIN 代替 JOIN
WHERE (type_val IS NULL OR d.type_id = ANY(type_val))
AND (er.evaluated_results->>'result') = 'false' -- 去除不必要的类型转换
AND er.created_at BETWEEN start_time AND end_time -- 使用 BETWEEN
AND (userid IS NULL OR d.user_id = userid);
END;
$function$;
6. 前端优化
使用 SWR 缓存:
import useSWR from 'swr';
function HomePage() {
const { data, error, isLoading } = useSWR(
'/api/home-data?typeId=1', // 单个 ID
// 或 '/api/home-data?typeId=2,3', // 多个 ID
// 或 '/api/home-data', // 不筛选类型
fetcher,
{
refreshInterval: 300000, // 5 分钟自动刷新
revalidateOnFocus: false, // 切换标签页不重新请求
dedupingInterval: 60000 // 1 分钟内去重
}
);
return <Dashboard data={data} />;
}
使用示例
URL 参数传递方式
通过 URL 查询参数传递 typeId:
# 查询单个类型
GET /home?typeId=1
# 查询多个类型(逗号分隔)
GET /home?typeId=2,3
# 不筛选类型
GET /home
函数调用方式
// 方式 1: 单个 ID
await getHomeData(1, userId, token);
// 方式 2: ID 数组
await getHomeData([2, 3], userId, token);
// 方式 3: 不筛选
await getHomeData(null, userId, token);
// 或
await getHomeData(undefined, userId, token);
使用场景说明
| 场景 | typeIds 值 | 说明 | 适用情况 |
|---|---|---|---|
| 查询合同类型 | 1 |
单个 type_id = 1 | 合同审核专用页面 |
| 查询记录类型 | [2, 3] |
type_id = 2 或 3 | 许可卷宗审核页面 |
| 查询所有类型 | null 或 undefined |
不筛选类型 | 首页总览 |
| 查询多种类型 | [1, 2, 3] |
type_id = 1 或 2 或 3 | 综合审核页面 |
Remix Loader 中使用
// app/routes/home.tsx
import { json, type LoaderFunctionArgs } from "@remix-run/node";
import { useLoaderData } from "@remix-run/react";
import { getHomeData } from "~/api/home/home";
import { getUserSession } from "~/api/login/auth.server";
export async function loader({ request }: LoaderFunctionArgs) {
try {
// 获取用户会话
const { frontendJWT, userId } = await getUserSession(request);
// 获取 URL 参数
const url = new URL(request.url);
const typeIdParam = url.searchParams.get('typeId');
// 解析 typeIds 参数
let typeIds: number[] | number | null = null;
if (typeIdParam) {
// 支持单个 ID: ?typeId=1
// 或多个 ID: ?typeId=2,3
const ids = typeIdParam.split(',').map(id => parseInt(id.trim())).filter(id => !isNaN(id));
typeIds = ids.length === 1 ? ids[0] : ids.length > 1 ? ids : null;
}
// 调用 getHomeData
const homeData = await getHomeData(typeIds, userId, frontendJWT);
return json({ homeData, typeIds });
} catch (error) {
console.error('加载首页数据失败:', error);
return json(
{
error: '加载首页数据失败',
homeData: null
},
{ status: 500 }
);
}
}
export default function HomePage() {
const { homeData, typeIds } = useLoaderData<typeof loader>();
if (!homeData) {
return <div>加载失败</div>;
}
// 根据 typeIds 显示标题
const getTitle = () => {
if (!typeIds) return '全部文档审核';
if (typeIds === 1 || (Array.isArray(typeIds) && typeIds.includes(1))) return '合同审核';
if (Array.isArray(typeIds) && typeIds.includes(2)) return '记录审核';
return '文档审核';
};
return (
<div className="home-page">
<h1>{getTitle()} - 数据统计</h1>
<div className="stats-grid">
<StatCard
title="今日待审核"
value={homeData.todayPendingFiles}
icon="⏳"
/>
<StatCard
title="本月已审核"
value={homeData.monthlyReviewedFiles}
growth={homeData.monthlyReviewGrowth}
icon="📄"
/>
<StatCard
title="审核通过率"
value={`${homeData.monthlyPassRate}%`}
growth={homeData.passRateGrowth}
icon="✅"
/>
<StatCard
title="检测到的问题"
value={homeData.issuesDetected}
growth={homeData.issuesGrowth}
icon="⚠️"
/>
</div>
</div>
);
}
注意事项
1. 日期字段不一致问题
当前实现中的不一致:
| 查询 | 使用字段 | 说明 |
|---|---|---|
| 今日待审核文件 | created_at |
创建时间 |
| 本月已审核文件 | upload_time |
上传时间 |
| 本月审核通过数 | created_at |
创建时间 |
| 上月审核通过数 | upload_time |
上传时间 |
潜在问题:
- 同一个文档的
created_at和upload_time可能不同 - 导致统计口径不一致
建议: 统一使用 created_at 或 upload_time
2. 增长率计算的边界情况
特殊情况处理:
| 本月 | 上月 | 处理方式 | 结果 |
|---|---|---|---|
| > 0 | > 0 | 正常计算 | 实际增长率 |
| > 0 | = 0 | 固定返回 | 100% 增长 |
| = 0 | = 0 | 不计算 | 0% 增长 |
| = 0 | > 0 | 正常计算 | -100% 增长 |
代码逻辑:
if (lastMonth > 0) {
// 正常计算
} else if (lastMonth == 0 && thisMonth > 0) {
// 特殊处理:固定 100%
} else {
// 默认 0%
}
3. Token 传递
重要: 所有 PostgREST 查询都必须传递 JWT Token
// ✅ 正确
await postgrestGet('documents', { ...params, token });
// ❌ 错误(会被 401 拒绝)
await postgrestGet('documents', params);
4. 类型转换
userId 参数: 需要转换为整数(用于 RPC 调用)
const response = await postgrestPost('rpc/count_evaluation_results_by_type', {
userid: parseInt(userId as string) // ✅ 转换为整数
}, token);
总结
核心功能
getHomeData() 函数实现了首页数据统计的完整业务逻辑,包括:
- ✅ 7 项核心指标统计
- ✅ 3 种同比增长计算
- ✅ 灵活的类型筛选 (支持单个 ID、ID 数组或不筛选)
- ✅ 完善的错误处理
- ✅ 统一的 API 响应格式处理
数据库例程
count_evaluation_results_by_type 函数提供了:
- ✅ 高效的问题统计查询
- ✅ 灵活的参数筛选 (类型、用户、时间范围)
- ✅ JSONB 字段查询优化
优化建议
- 🚀 并行查询 - 性能提升 ~7 倍
- 💾 Redis 缓存 - 减少数据库负载
- 📊 物化视图 - 查询时间降低 ~10 倍
- 🔍 索引优化 - 提升查询效率
文档版本: 2.1 最后更新: 2025-01-17 更新内容: 将 reviewType 参数改为直接传递 typeIds (支持单个 ID、ID 数组或不筛选) 维护者: 系统开发团队