Files
leaudit-platform-frontend/docs/getHomeData_完整逻辑文档.md
T
2025-12-05 00:09:32 +08:00

44 KiB
Raw Blame History

getHomeData 首页数据统计完整逻辑文档

文档信息

  • 文件路径: app/api/home/home.ts
  • 主函数: getHomeData()
  • 创建日期: 2025-01-17
  • 文档版本: 2.1
  • 更新说明: 改用 typeIds 参数直接指定文档类型 ID

目录

  1. 函数概述
  2. 函数签名与返回值
  3. 业务逻辑流程
  4. 时间范围计算
  5. 数据查询详解
  6. 数据库例程详解
  7. 增长率计算公式
  8. PostgREST 请求示例
  9. 完整代码流程图
  10. 错误处理机制
  11. 性能优化建议

函数概述

getHomeData() 是首页数据统计的核心函数,用于获取以下 6 项关键业务指标:

  1. 今日待审核文件数 - 统计今天创建的待审核文件
  2. 本月已审核文件数 - 统计本月完成审核的文件总数
  3. 审核文件同比增长 - 与上月对比的增长百分比
  4. 本月审核通过率 - 本月通过文件数 / 本月已审核文件数
  5. 通过率同比增长 - 与上月通过率对比的增长百分比
  6. 检测到的问题总数 - 从评估结果中统计问题数量
  7. 问题数量同比增长 - 与上月问题数量对比的增长百分比

函数签名与返回值

函数签名

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)
- nullundefined: 不筛选类型
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 用户 IDNULL 表示不筛选用户

返回值

返回一个表,包含单列 count(BIGINT 类型),表示符合条件的问题数量。

业务逻辑

  1. 关联查询: 从 evaluation_results 表关联 documents
  2. 类型筛选: 如果 type_val 不为 NULL,则筛选指定类型的文档
  3. 问题筛选: 只统计 evaluated_results.result = 'false' 的记录(表示有问题)
  4. 时间范围: 筛选 er.created_at 在指定时间范围内的记录
  5. 用户筛选: 如果 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_valNULL,则不筛选类型
  • 否则使用 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 }
  };
}

错误处理策略:

  1. 单个查询失败: 返回默认值(0 或空数组),记录错误日志,不中断整体流程
  2. 整体失败: 返回完整的默认数据结构,确保页面可以正常渲染

数据提取函数: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. 格式1 (9000端口):
{
  "code": 0,
  "msg": "success",
  "data": [{ "count": 10 }]
}
  1. 格式2 (8000端口):
[{ "count": 10 }]
  1. 错误响应:
{
  "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 许可卷宗审核页面
查询所有类型 nullundefined 不筛选类型 首页总览
查询多种类型 [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_atupload_time 可能不同
  • 导致统计口径不一致

建议: 统一使用 created_atupload_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() 函数实现了首页数据统计的完整业务逻辑,包括:

  1. 7 项核心指标统计
  2. 3 种同比增长计算
  3. 灵活的类型筛选 (支持单个 ID、ID 数组或不筛选)
  4. 完善的错误处理
  5. 统一的 API 响应格式处理

数据库例程

count_evaluation_results_by_type 函数提供了:

  1. 高效的问题统计查询
  2. 灵活的参数筛选 (类型、用户、时间范围)
  3. JSONB 字段查询优化

优化建议

  1. 🚀 并行查询 - 性能提升 ~7 倍
  2. 💾 Redis 缓存 - 减少数据库负载
  3. 📊 物化视图 - 查询时间降低 ~10 倍
  4. 🔍 索引优化 - 提升查询效率

文档版本: 2.1 最后更新: 2025-01-17 更新内容: 将 reviewType 参数改为直接传递 typeIds (支持单个 ID、ID 数组或不筛选) 维护者: 系统开发团队