diff --git a/app/api/cross-checking/cross-file-result.ts b/app/api/cross-checking/cross-file-result.ts index 2185bd7..d896eb1 100644 --- a/app/api/cross-checking/cross-file-result.ts +++ b/app/api/cross-checking/cross-file-result.ts @@ -136,7 +136,7 @@ export async function submitCrossCheckingOpinion( evaluation_result_id: opinionData.reviewPointResultId }; - const response = await axios.post(`${API_BASE_URL}/admin/cross_review/proposals`, requestData, { + const response = await axios.post(`${API_BASE_URL}/api/v2/cross_review/proposals`, requestData, { headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${token}` @@ -196,8 +196,8 @@ export async function getCrossCheckingOpinions( // 如果没传userId,默认用1 const realUserId = userId ?? 1; // 实际后端API调用,拼接API_BASE_URL - const response = await axios.post(`${API_BASE_URL}/admin/cross_review/proposals/document`, { - user_id: realUserId, + const response = await axios.post(`${API_BASE_URL}/api/v2/cross_review/proposals/document`, { + // user_id: realUserId, document_id: documentId, // 如果后端需要document_id可以加上 page, page_size: pageSize @@ -209,7 +209,7 @@ export async function getCrossCheckingOpinions( }); const data = response.data; - console.log('最原始的返回data', data); + // console.log('最原始的返回data', data); // 处理新的数据结构,支持分页 const responseData = data.data || data; const pagination = data.pagination; @@ -318,24 +318,24 @@ export async function performOpinionAction( switch (actionData.action) { case 'agree': message = '已赞同该意见'; - endpoint = `${API_BASE_URL}/admin/cross_review/proposals/${actionData.opinionId}/votes`; + endpoint = `${API_BASE_URL}/api/v2/cross_review/proposals/${actionData.opinionId}/votes`; requestBody = { vote_type: 'agree', voter_id: userInfo?.user_id }; break; case 'disagree': message = '已反对该意见'; - endpoint = `${API_BASE_URL}/admin/cross_review/proposals/${actionData.opinionId}/votes`; + endpoint = `${API_BASE_URL}/api/v2/cross_review/proposals/${actionData.opinionId}/votes`; requestBody = { vote_type: 'disagree', voter_id: userInfo?.user_id }; break; case 'withdraw_vote': message = '已撤销投票'; // 撤销投票的接口,根据实际API调整 - endpoint = `${API_BASE_URL}/admin/cross_review/proposals/${actionData.opinionId}/votes`; + endpoint = `${API_BASE_URL}/api/v2/cross_review/proposals/${actionData.opinionId}/votes`; requestBody = { vote_type: 'cancel', voter_id: userInfo?.user_id }; break; case 'withdraw_opinion': message = '已撤销意见'; // 撤销意见的接口,根据实际API调整 - endpoint = `${API_BASE_URL}/admin/cross_review/proposals/${actionData.opinionId}`; + endpoint = `${API_BASE_URL}/api/v2/cross_review/proposals/${actionData.opinionId}`; requestBody = {}; break; default: @@ -389,43 +389,77 @@ export async function performOpinionAction( /** - * 完成评查 + * 完成评查(确认文档审核完成) + * @param taskId 任务ID * @param documentId 文档ID + * @param frontendJWT JWT token * @returns 完成评查结果 + * + * 🔥 接口文档: auth_doc/交叉评查接口文档(1).md 接口10 + * 📍 API地址: POST /admin/v2/cross_review/tasks/{task_id}/documents/{document_id}/complete */ export async function confirmReviewResults( + taskId: string | number, documentId: string | number, frontendJWT?: string ): Promise<{data?: unknown, error?: string, status?: number}> { try { - // 通过postgrest的post请求去documents表中进行查找id等于documentId的数据,更新documents表的audit_status为1 - const response = await postgrestPut(`/api/postgrest/proxy/documents`, { - audit_status: 1 - }, { - id: documentId - }, frontendJWT); - if(response.error) { + if (!taskId) { + return { error: '任务ID不能为空', status: 400 }; + } + if (!documentId) { + return { error: '文档ID不能为空', status: 400 }; + } + + // 调用后端API确认文档审核完成 + // 接口: POST /admin/v2/cross_review/tasks/{task_id}/documents/{document_id}/complete + const response = await axios.post( + `${API_BASE_URL}/admin/v2/cross_review/tasks/${taskId}/documents/${documentId}/complete`, + {}, // 无需请求体 + { + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${frontendJWT}` + } + } + ); + + const data = response.data; + + // 检查响应是否成功 + if (data?.success || data?.code === 0) { return { - error: response.error, - status: response.status - }; - } - const extractedData = extractApiData(response.data); - if(!extractedData) { - return { - error: '更新文档状态失败', - status: 500 + data: { + task_id: data.task_id || taskId, + document_id: data.document_id || documentId, + message: data.message || '文档评查已完成' + } }; } + + // 数据为空或格式不正确 + console.error('❌ [confirmReviewResults] API响应数据异常:', data); return { - data: extractedData + error: data?.message || '确认文档审核失败', + status: 500 }; - + } catch (error) { console.error('完成评查失败:', error); + + // 正确处理 axios 错误响应 + let errorMessage = '完成评查失败'; + + if (axios.isAxiosError(error) && error.response?.data) { + // 从 axios 错误响应中提取 detail 或 msg 字段 + errorMessage = error.response.data.detail || error.response.data.msg || errorMessage; + } else if (error instanceof Error) { + errorMessage = error.message || errorMessage; + } + return { - error: error instanceof Error ? error.message : '完成评查失败', - status: 500 + error: errorMessage, + status: axios.isAxiosError(error) ? error.response?.status || 500 : 500 }; } } @@ -444,7 +478,7 @@ export async function checkProposalVotes( document_id: documentId }; - const response = await axios.post(`${API_BASE_URL}/admin/cross_review/proposals/document/check_pending_votes`, requestData, { + const response = await axios.post(`${API_BASE_URL}/api/v2/cross_review/proposals/document/check_pending_votes`, requestData, { headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${token}` diff --git a/app/api/cross-checking/cross-files.ts b/app/api/cross-checking/cross-files.ts index 802ad5e..d807f03 100644 --- a/app/api/cross-checking/cross-files.ts +++ b/app/api/cross-checking/cross-files.ts @@ -403,7 +403,7 @@ export async function getUserTaskDocuments(page: number = 1, pageSize: number = try { // 拼接绝对路径,去除多余斜杠 const base = API_BASE_URL.endsWith('/') ? API_BASE_URL.slice(0, -1) : API_BASE_URL; - const url = `${base}/admin/cross_review/tasks/user_tasks`; + const url = `${base}/admin/v2/cross_review/tasks/user_tasks`; const response = await axios.post(url, { page: page, @@ -445,7 +445,7 @@ export async function getTaskDocuments(taskId: number, page: number = 1, pageSiz try { // 拼接绝对路径,去除多余斜杠 const base = API_BASE_URL.endsWith('/') ? API_BASE_URL.slice(0, -1) : API_BASE_URL; - const url = `${base}/admin/cross_review/tasks/${taskId}/documents`; + const url = `${base}/admin/v2/cross_review/tasks/${taskId}/documents`; // console.log('最终请求URL:', url); const response = await axios.post(url, { diff --git a/app/api/document-types/document-types.ts b/app/api/document-types/document-types.ts index 5717158..dcd95d8 100644 --- a/app/api/document-types/document-types.ts +++ b/app/api/document-types/document-types.ts @@ -66,6 +66,7 @@ export interface DocumentTypeSearchParams { name?: string; group_id?: number; // 按评查点分组ID筛选 entry_module_id?: number; // 按入口模块ID筛选 + ids?: number[]; // 按ID列表筛选 page?: number; pageSize?: number; } @@ -109,7 +110,7 @@ export async function getDocumentTypes( const pageSize = searchParams.pageSize || 10; // 构建查询参数 - const params: Record = { + const params: Record = { page, page_size: pageSize, }; @@ -126,6 +127,10 @@ export async function getDocumentTypes( params.entry_module_id = searchParams.entry_module_id; } + if (searchParams.ids) { + params.ids = searchParams.ids; + } + const response = await apiRequest>( '/api/v3/document-types', { diff --git a/app/api/evaluation_points/reviews.ts b/app/api/evaluation_points/reviews.ts index 7fee200..cfcbe9f 100644 --- a/app/api/evaluation_points/reviews.ts +++ b/app/api/evaluation_points/reviews.ts @@ -1,5 +1,5 @@ import { postgrestGet, type PostgrestParams, postgrestPut, postgrestPost } from "../postgrest-client"; -import {getDocumentWithNoUserId} from "~/api/files/documents"; +// import {getDocumentWithNoUserId} from "~/api/files/documents"; import dayjs from "dayjs"; import { getUserSession } from "~/api/login/auth.server"; import { apiRequest } from "../axios-client"; @@ -124,13 +124,13 @@ interface ScoringProposal { document_id: string | number; } -/** - * 获取当前评查文件的所有评查点结果 +/** ============== (废弃,已经采用api接口的方式进行查询) + * 获取当前评查文件的所有评查点结果 * @param fileId 评查文件ID * @param request Remix请求对象,用于获取用户会话 * @returns 评查点结果列表和统计数据 */ -export async function getReviewPoints(fileId: string, request: Request) { +export async function getReviewPoints(fileId: string, request: Request) { // 获取用户会话信息 const { userInfo, frontendJWT } = await getUserSession(request); @@ -744,17 +744,27 @@ export async function getReviewPoints(fileId: string, request: Request) { * 更新评查结果 * @param resultId 评查结果ID * @param editAuditStatusId 审核状态ID - * @param result 评查结果 (true/false) + * @param result 评查结果 (true/false/review) * @param message 评查意见 * @param request Remix请求对象,用于获取用户会话 + * @param documentId 文档ID(可选,用于创建新审核状态) + * @param evaluationPointId 评查点ID(可选,用于创建新审核状态) * @returns 更新后的评查结果 + * + * 🔥 接口文档: auth_doc/评查审核接口对接文档.md + * 📍 使用接口: + * - 3.1 更新评查结果: PATCH /admin/v2/evaluations/results/{result_id} + * - 3.2 创建审核状态: POST /admin/v2/evaluations/audit-status + * - 3.3 更新审核状态: PATCH /admin/v2/evaluations/audit-status/{audit_status_id} */ export async function updateReviewResult( - resultId: string, - editAuditStatusId: string | number, - result: string, + resultId: string, + editAuditStatusId: string | number, + result: string, message: string, - request: Request + request: Request, + documentId?: string | number, + evaluationPointId?: string | number ): Promise<{ data?: unknown; error?: string; @@ -763,122 +773,184 @@ export async function updateReviewResult( try { // 获取用户会话信息 const { userInfo, frontendJWT } = await getUserSession(request); - + if (!userInfo?.user_id) { console.error("用户身份验证失败"); return { error: '用户身份验证失败', status: 401 }; } - - const userId = userInfo.user_id; - + if (!resultId) { return { error: '评查结果ID不能为空', status: 400 }; } - - // 首先获取当前评查结果数据 - const currentResultResponse = await postgrestGet('/api/postgrest/proxy/evaluation_results', { - select: '*', - filter: { id: `eq.${resultId}` }, - token: frontendJWT - }); - - console.log('/api/postgrest/proxy/evaluation_results',currentResultResponse.error) - if (currentResultResponse.error) { - return { error: currentResultResponse.error, status: currentResultResponse.status }; - } - - const currentResultData = extractApiData(currentResultResponse.data); - - if (!currentResultData || !Array.isArray(currentResultData) || currentResultData.length === 0) { - return { error: '未找到评查结果数据', status: 404 }; - } - - const currentResult = currentResultData[0]; - const currentEvaluatedResults = currentResult.evaluated_results || {}; - + // 判断是否是重新审核操作 const isReview = result === 'review'; - // console.log('isReview-------', result); - - // 构建要更新的数据,保留原有字段 - const updatedEvaluatedResults = { - ...currentEvaluatedResults, - // 如果是重新审核操作,不更新result和message - ...(isReview ? {} : { result: result === 'true' ? true : false, message }), - }; - - const updatedData = { - evaluated_results: updatedEvaluatedResults - }; - - // 调用 API 更新评查点结果数据 - const resultResponse = await postgrestPut( - '/api/postgrest/proxy/evaluation_results', - updatedData, - { id: resultId }, - frontendJWT - ); - - if (resultResponse.error) { - return { error: resultResponse.error, status: resultResponse.status }; + + // ============================================ + // 步骤1: 调用3.1接口更新评查结果(如果不是重新审核操作) + // ============================================ + if (!isReview) { + // 构建请求数据 + const updateResultData: { + result?: 'true' | 'false'; + message?: string; + } = { + result: result === 'true' ? 'true' : 'false', + message: message + }; + + // 调用 3.1 接口: PATCH /admin/v2/evaluations/results/{result_id} + const resultResponse = await apiRequest<{ + success: boolean; + message: string; + data: { + result_id: number; + updated_fields: string[]; + }; + }>( + `/admin/v2/evaluations/results/${resultId}`, + { + method: 'PATCH', + headers: { + 'Authorization': `Bearer ${frontendJWT}`, + 'Content-Type': 'application/json' + }, + data: updateResultData + } + ); + + if (resultResponse.error) { + console.error('❌ [updateReviewResult] 3.1接口调用失败:', resultResponse.error); + return { error: resultResponse.error, status: resultResponse.status || 500 }; + } + + if (!resultResponse.data?.success) { + console.error('❌ [updateReviewResult] 3.1接口响应异常:', resultResponse.data); + return { error: resultResponse.data?.message || '更新评查结果失败', status: 500 }; + } } - - // 处理audit_status表的更新或新增 + + // ============================================ + // 步骤2: 处理审核状态(创建或更新) + // ============================================ // 确定edit_audit_status的值: // 如果是重新审核操作,值为0;否则值为1 const editAuditStatusValue = isReview ? 0 : 1; - // console.log('editAuditStatusValue-------', editAuditStatusValue); - // console.log('editAuditStatusId-------', editAuditStatusId); + if (editAuditStatusId && editAuditStatusId !== '') { - // 更新现有审核状态记录 - const auditStatusResponse = await postgrestPut( - '/api/postgrest/proxy/audit_status', - { - edit_audit_status: editAuditStatusValue, - // 重新审核时不更新message - ...(isReview ? {} : { message }) - }, - { - id: editAuditStatusId, - user_id: userId // 添加用户ID条件,确保只能更新自己的记录 - }, - frontendJWT + // ============================================ + // 使用3.3接口更新现有审核状态记录 + // PATCH /admin/v2/evaluations/audit-status/{audit_status_id} + // ============================================ + const updateAuditData: { + edit_audit_status: number; + message?: string; + } = { + edit_audit_status: editAuditStatusValue + }; + + // 重新审核时不更新message + if (!isReview) { + updateAuditData.message = message; + } + + const auditStatusResponse = await apiRequest<{ + success: boolean; + message: string; + data: { + audit_status_id: number; + updated_fields: string[]; + }; + }>( + `/admin/v2/evaluations/audit-status/${editAuditStatusId}`, + { + method: 'PATCH', + headers: { + 'Authorization': `Bearer ${frontendJWT}`, + 'Content-Type': 'application/json' + }, + data: updateAuditData + } ); - + if (auditStatusResponse.error) { + console.error('❌ [updateReviewResult] 3.3接口调用失败:', auditStatusResponse.error); return { error: auditStatusResponse.error, status: auditStatusResponse.status || 500 }; } - } else { - // 如果没有editAuditStatusId,则创建新记录 - // 首先获取文档ID和评查点ID - const documentId = currentResult.document_id; - const evaluationPointId = currentResult.evaluation_point_id; - - // 创建新的审核状态记录 - const newAuditStatus = { - document_id: documentId, - evaluation_point_id: evaluationPointId, - evaluation_result_id: resultId, - edit_audit_status: editAuditStatusValue, - message: isReview ? '' : message, - user_id: userId // 添加用户ID - }; - - // 使用postgrestPost创建新记录 - const postResponse = await postgrestPost('/api/postgrest/proxy/audit_status', newAuditStatus, frontendJWT); - - if (postResponse.error) { - return { error: postResponse.error, status: postResponse.status || 500 }; + + if (!auditStatusResponse.data?.success) { + console.error('❌ [updateReviewResult] 3.3接口响应异常:', auditStatusResponse.data); + return { error: auditStatusResponse.data?.message || '更新审核状态失败', status: 500 }; } + + return { data: auditStatusResponse.data }; + } else { + // ============================================ + // 使用3.2接口创建新审核状态记录 + // POST /admin/v2/evaluations/audit-status + // ============================================ + + // 如果没有传入documentId和evaluationPointId,需要先获取 + if (!documentId || !evaluationPointId) { + // 从评查结果中获取document_id和evaluation_point_id + const currentResultResponse = await postgrestGet('/api/postgrest/proxy/evaluation_results', { + select: 'document_id,evaluation_point_id', + filter: { id: `eq.${resultId}` }, + token: frontendJWT + }); + + if (currentResultResponse.error) { + return { error: currentResultResponse.error, status: currentResultResponse.status }; + } + + const currentResultData = extractApiData(currentResultResponse.data); + + if (!currentResultData || !Array.isArray(currentResultData) || currentResultData.length === 0) { + return { error: '未找到评查结果数据', status: 404 }; + } + + documentId = currentResultData[0].document_id; + evaluationPointId = currentResultData[0].evaluation_point_id; + } + + // 创建新的审核状态记录 + const newAuditStatusData = { + document_id: Number(documentId), + evaluation_point_id: Number(evaluationPointId), + evaluation_result_id: Number(resultId), + edit_audit_status: editAuditStatusValue, + message: isReview ? '' : message + }; + + const createAuditResponse = await apiRequest<{ + id: number; + user_id: number; + document_id: number; + evaluation_point_id: number; + evaluation_result_id: number; + edit_audit_status: number; + message: string; + created_at: string; + updated_at: string; + }>( + `/admin/v2/evaluations/audit-status`, + { + method: 'POST', + headers: { + 'Authorization': `Bearer ${frontendJWT}`, + 'Content-Type': 'application/json' + }, + data: newAuditStatusData + } + ); + + if (createAuditResponse.error) { + console.error('❌ [updateReviewResult] 3.2接口调用失败:', createAuditResponse.error); + return { error: createAuditResponse.error, status: createAuditResponse.status || 500 }; + } + + return { data: createAuditResponse.data }; } - - const extractedData = extractApiData(resultResponse.data); - - if (!extractedData) { - return { error: '更新评查结果失败', status: 500 }; - } - - return { data: extractedData }; } catch (error) { console.error('更新评查结果失败:', error); return { @@ -893,6 +965,9 @@ export async function updateReviewResult( * @param documentId 文档ID * @param request Remix请求对象,用于获取用户会话 * @returns 更新结果 + * + * 🔥 接口文档: auth_doc/评查审核接口对接文档.md 3.4 + * 📍 API地址: PATCH /admin/v2/evaluation/documents/{document_id}/confirm */ export async function confirmReviewResults(documentId: string, request: Request): Promise<{ data?: { auditStatus: number; }; @@ -902,57 +977,53 @@ export async function confirmReviewResults(documentId: string, request: Request) try { // 获取用户会话信息 const { userInfo, frontendJWT } = await getUserSession(request); - + if (!userInfo?.user_id) { console.error("用户身份验证失败"); return { error: '用户身份验证失败', status: 401 }; } - - const userId = userInfo.user_id; - + if (!documentId) { return { error: '文档ID不能为空', status: 400 }; } - - // 获取该文档的所有评查点结果 - // const reviewPointsResponse = await getReviewPoints(documentId); - - // if ('error' in reviewPointsResponse && reviewPointsResponse.error) { - // return { error: reviewPointsResponse.error, status: reviewPointsResponse.status }; - // } - - // if (!('data' in reviewPointsResponse) || !reviewPointsResponse.data || !Array.isArray(reviewPointsResponse.data)) { - // return { error: '获取评查点数据失败', status: 500 }; - // } - - // // 计算总分数 - // const totalScore = reviewPointsResponse.stats?.score || 0; - - // // 根据总分确定审核状态 - // // <80分:不通过(-1),>=80分:通过(1) - // const auditStatus = totalScore < 80 ? -1 : 1; - - // 更新文档的审核状态 - const updateDocumentParams = { - audit_status: 1 - }; - - // 调用API更新文档审核状态 - const response = await postgrestPut<{ id: string }, typeof updateDocumentParams>( - '/api/postgrest/proxy/documents', - updateDocumentParams, - { - id: documentId, - user_id: userId // 添加用户ID条件,确保只能更新自己的文档 - }, - frontendJWT + + // 调用后端API确认文档审核完成 + // 接口: PATCH /admin/v2/evaluations/documents/{document_id}/confirm + const response = await apiRequest<{ + success: boolean; + message: string; + data: { + document_id: number; + audit_status: number; + }; + }>( + `/admin/v2/evaluations/documents/${documentId}/confirm`, + { + method: 'PATCH', + headers: { + 'Authorization': `Bearer ${frontendJWT}`, + 'Content-Type': 'application/json' + }, + data: { + audit_status: 1 + } + } ); - + + // 处理错误响应 if (response.error) { - return { error: response.error, status: response.status }; + console.error('❌ [confirmReviewResults] API调用失败:', response.error); + return { error: response.error, status: response.status || 500 }; } - - return { data: { auditStatus: 1} }; + + // 成功响应 + if (response.data?.success) { + return { data: { auditStatus: response.data.data?.audit_status || 1 } }; + } + + // 数据为空或格式不正确 + console.error('❌ [confirmReviewResults] API响应数据异常:', response.data); + return { error: response.data?.message || '确认文档审核失败', status: 500 }; } catch (error) { console.error('确认评查结果失败:', error); return { diff --git a/app/api/evaluation_points/rules.ts b/app/api/evaluation_points/rules.ts index c4a78f2..c1d90f6 100644 --- a/app/api/evaluation_points/rules.ts +++ b/app/api/evaluation_points/rules.ts @@ -434,254 +434,7 @@ export async function getRule(id: string, token?: string): Promise<{data: Rule; } } -/** - * 创建新评查点 - * @param ruleData 评查点数据 - * @param token JWT token (可选) - * @returns 创建的评查点 - */ -export async function createRule(ruleData: Omit, token?: string): Promise<{data: Rule; error?: never} | {data?: never; error: string; status?: number}> { - try { - // 1. 验证必填字段 - if (!ruleData.name || !ruleData.code) { - return { error: '评查点名称和编码不能为空', status: 400 }; - } - // 2. 验证名称长度(1-100字符) - const trimmedName = ruleData.name.trim(); - if (trimmedName.length === 0 || trimmedName.length > 100) { - return { error: '评查点名称长度必须在1-100个字符之间', status: 400 }; - } - - // 3. 验证编码格式(仅允许字母、数字、连字符和下划线) - const trimmedCode = ruleData.code.trim(); - if (!/^[a-zA-Z0-9-_]+$/.test(trimmedCode)) { - return { error: '评查点编码只能包含字母、数字、连字符和下划线', status: 400 }; - } - - // 4. 验证编码唯一性 - const existingRulesResponse = await getRulesList({ - keyword: trimmedCode, - pageSize: 10, - token - }); - - if (existingRulesResponse.data && existingRulesResponse.data.rules.length > 0) { - // 精确匹配检查(因为keyword是模糊搜索) - const exactMatch = existingRulesResponse.data.rules.some(r => r.code === trimmedCode); - if (exactMatch) { - return { error: '评查点编码已存在,请使用其他编码', status: 409 }; - } - } - - // 5. 验证分组ID有效性 - if (!ruleData.groupId) { - return { error: '必须选择所属规则组', status: 400 }; - } - - // 检查分组是否存在 - const groupResponse = await postgrestGet<{code: number; msg: string; data: Array<{id: number; name: string; pid: number}> }>('/api/postgrest/proxy/evaluation_point_groups', { - filter: { 'id': `eq.${ruleData.groupId}` }, - select: 'id,name,pid', - token - }); - - let groupExists = false; - if (groupResponse.data && 'code' in groupResponse.data && groupResponse.data.data) { - groupExists = Array.isArray(groupResponse.data.data) && groupResponse.data.data.length > 0; - } else if (Array.isArray(groupResponse.data)) { - groupExists = groupResponse.data.length > 0; - } - - if (!groupExists) { - return { error: '所选规则组不存在', status: 404 }; - } - - // 将前端模型转换为API接受的格式 - const apiRuleData = { - code: trimmedCode, - name: trimmedName, - evaluation_point_groups_id: parseInt(ruleData.groupId), - risk: ruleData.priority === 'high' ? '高' : ruleData.priority === 'medium' ? '中' : '低', - description: ruleData.description || '', - is_enabled: ruleData.isActive !== undefined ? ruleData.isActive : true, - // 以下是默认值,实际应用中需要根据业务逻辑设置 - references_laws: {}, - extraction_config: { - type: "OCR+LLM", - fields: [] - }, - evaluation_config: { - rules: [], - logicType: "and" - }, - pass_message: "", - fail_message: "", - suggestion_message: "", - suggestion_message_type: "warning", - post_action: "none", - action_config: "" - }; - - // 使用postgrestPost创建评查点 - const response = await postgrestPost<{code: number; msg: string; data: ApiRule}, typeof apiRuleData>('/api/postgrest/proxy/evaluation_points', apiRuleData, token); - - // 检查是否有错误响应 - if (response.error) { - return { error: response.error, status: response.status }; - } - - // 确保响应数据存在且符合预期格式 - if (!response.data || !response.data.data) { - return { error: '接口返回数据格式不正确', status: 500 }; - } - - // 将API返回的数据映射到前端模型 - const rule = mapApiRuleToFrontendModel(response.data.data); - - return { data: rule }; - } catch (error) { - console.error('创建评查点出错:', error); - return { - error: error instanceof Error ? error.message : '创建评查点失败', - status: 500 - }; - } -} - -/** - * 更新评查点 - * @param id 评查点ID - * @param ruleData 评查点数据 - * @param token JWT token (可选) - * @returns 更新后的评查点 - */ -export async function updateRule(id: string, ruleData: Partial>, token?: string): Promise<{data: Rule; error?: never} | {data?: never; error: string; status?: number}> { - try { - // 1. 验证评查点ID有效性 - const existingRuleResponse = await getRule(id, token); - if (existingRuleResponse.error || !existingRuleResponse.data) { - return { error: '评查点不存在', status: 404 }; - } - - // 2. 验证名称长度(如果提供) - if (ruleData.name !== undefined) { - const trimmedName = ruleData.name.trim(); - if (trimmedName.length === 0 || trimmedName.length > 100) { - return { error: '评查点名称长度必须在1-100个字符之间', status: 400 }; - } - } - - // 3. 验证编码格式和唯一性(如果提供) - if (ruleData.code !== undefined) { - const trimmedCode = ruleData.code.trim(); - - // 验证编码格式 - if (!/^[a-zA-Z0-9-_]+$/.test(trimmedCode)) { - return { error: '评查点编码只能包含字母、数字、连字符和下划线', status: 400 }; - } - - // 验证编码唯一性(排除自身) - const existingRulesResponse = await getRulesList({ - keyword: trimmedCode, - pageSize: 10, - token - }); - - if (existingRulesResponse.data && existingRulesResponse.data.rules.length > 0) { - // 精确匹配检查,排除当前评查点自身 - const exactMatch = existingRulesResponse.data.rules.some(r => r.code === trimmedCode && r.id !== id); - if (exactMatch) { - return { error: '评查点编码已被其他评查点使用', status: 409 }; - } - } - } - - // 4. 验证分组ID有效性(如果提供) - if (ruleData.groupId !== undefined) { - const groupResponse = await postgrestGet<{code: number; msg: string; data: Array<{id: number; name: string; pid: number}> }>('/api/postgrest/proxy/evaluation_point_groups', { - filter: { 'id': `eq.${ruleData.groupId}` }, - select: 'id,name,pid', - token - }); - - let groupExists = false; - if (groupResponse.data && 'code' in groupResponse.data && groupResponse.data.data) { - groupExists = Array.isArray(groupResponse.data.data) && groupResponse.data.data.length > 0; - } else if (Array.isArray(groupResponse.data)) { - groupExists = groupResponse.data.length > 0; - } - - if (!groupExists) { - return { error: '所选规则组不存在', status: 404 }; - } - } - - // 构建API接受的更新数据 - const apiRuleData: Record = {}; - - if (ruleData.code !== undefined) { - apiRuleData.code = ruleData.code.trim(); - } - - if (ruleData.name !== undefined) { - apiRuleData.name = ruleData.name.trim(); - } - - if (ruleData.groupId !== undefined) { - apiRuleData.evaluation_point_groups_id = parseInt(ruleData.groupId); - } - - if (ruleData.priority !== undefined) { - apiRuleData.risk = ruleData.priority === 'high' ? '高' : ruleData.priority === 'medium' ? '中' : '低'; - } - - if (ruleData.description !== undefined) { - apiRuleData.description = ruleData.description; - } - - if (ruleData.isActive !== undefined) { - apiRuleData.is_enabled = ruleData.isActive; - } - - // 使用postgrestPut更新评查点 - 使用正确的PostgREST格式 - const response = await postgrestPut<{code: number; msg: string; data: ApiRule} | ApiRule[], typeof apiRuleData>('/api/postgrest/proxy/evaluation_points', apiRuleData, { id: parseInt(id) }, token); - - // 检查是否有错误响应 - if (response.error) { - return { error: response.error, status: response.status }; - } - - // 处理响应数据(PostgREST可能返回数组或包装对象) - let updatedRule: ApiRule | null = null; - - if (response.data) { - // 如果是数组格式(PostgREST标准响应) - if (Array.isArray(response.data)) { - updatedRule = response.data.length > 0 ? response.data[0] : null; - } - // 如果是包装对象格式 - else if ('data' in response.data && response.data.data) { - updatedRule = response.data.data as ApiRule; - } - } - - if (!updatedRule) { - return { error: '更新成功但无法获取更新后的数据', status: 500 }; - } - - // 将API返回的数据映射到前端模型 - const rule = mapApiRuleToFrontendModel(updatedRule); - - return { data: rule }; - } catch (error) { - console.error('更新评查点出错:', error); - return { - error: error instanceof Error ? error.message : '更新评查点失败', - status: 500 - }; - } -} /** * 删除评查点 @@ -722,47 +475,7 @@ export async function deleteRule(id: string, token?: string): Promise<{data: {su } } -/** - * 复制评查点 - * @param id 评查点ID - * @param token JWT token (可选) - * @returns 新创建的评查点 - */ -export async function duplicateRule(id: string, token?: string): Promise<{data: Rule; error?: never} | {data?: never; error: string; status?: number}> { - try { - // 1. 获取原评查点详情 - const ruleResponse = await getRule(id, token); - - if (ruleResponse.error || !ruleResponse.data) { - return { error: ruleResponse.error || '获取评查点详情失败', status: 500 }; - } - - // 2. 准备新评查点数据 - const rule = ruleResponse.data; - - // 创建新评查点对象 - const newRuleData = { - code: `${rule.code}-COPY`, - name: `${rule.name} (复制)`, - ruleType: rule.ruleType, - groupId: rule.groupId, - groupName: rule.groupName, - priority: rule.priority, - description: rule.description, - isActive: rule.isActive - }; - - // 3. 创建新评查点 - return createRule(newRuleData, token); - - } catch (error) { - console.error('复制评查点出错:', error); - return { - error: error instanceof Error ? error.message : '复制评查点失败', - status: 500 - }; - } -} + /** * 评查点类型 @@ -1143,137 +856,6 @@ export function convertApiRuleToFormData(apiRule: ApiRule): FormattedEvaluationP return formattedData; } -/** - * 获取单个评查点数据 - * @param id 评查点ID - * @returns 评查点数据 - */ -/** - * 获取格式化的评查点数据(用于列表视图) - * @param id 评查点ID - * @returns 格式化的评查点数据 - */ -export async function getFormattedEvaluationPoint(id: number): Promise<{ - data?: FormattedEvaluationPoint; - error?: string; - status?: number; -}> { - try { - // console.log(`获取评查点数据,ID: ${id}`); - - // 使用 postgrestGet 替代直接调用 fetch - const postgrestParams: PostgrestParams = { - select: `*`, - filter: { - 'id': `eq.${id}` - } - }; - - const response = await postgrestGet<{code: number; msg: string; data: ApiRule[]} | ApiRule[]>('/api/postgrest/proxy/evaluation_points', postgrestParams); - - if (response.error) { - return { - error: response.error, - status: response.status - }; - } - - // 使用 extractApiData 统一处理响应数据 - const extractedData = extractApiData(response.data); - - if (extractedData && Array.isArray(extractedData) && extractedData.length > 0) { - // 转换数据为前端格式 - const formattedData = convertApiRuleToFormData(extractedData[0]); - return { data: formattedData }; - } else { - return { - error: '获取数据失败: 返回数据为空', - status: 404 - }; - } - } catch (error) { - console.error('获取评查点数据失败:', error); - return { - error: error instanceof Error ? error.message : '获取评查点数据失败', - status: 500 - }; - } -} - -/** - * 获取评查点组数据 - * @returns 评查点组列表 - */ -export async function getEvaluationPointGroups(): Promise<{ - data?: Array<{ - id: number; - pid: number; - code: string; - name: string; - description: string; - is_enabled: boolean; - created_at: string; - updated_at: string; - }>; - error?: string; - status?: number; -}> { - try { - // console.log("获取评查点组数据"); - - // 使用 postgrestGet 替代直接调用 fetch - const postgrestParams: PostgrestParams = { - select: `*` - }; - - // 定义评查点组类型 - type EvaluationPointGroupType = { - id: number; - pid: number; - code: string; - name: string; - description: string; - is_enabled: boolean; - created_at?: string; - updated_at?: string; - }; - - const response = await postgrestGet<{code: number; msg: string; data: EvaluationPointGroupType[]} | EvaluationPointGroupType[]>('/api/postgrest/proxy/evaluation_point_groups', postgrestParams); - - if (response.error) { - return { - error: response.error, - status: response.status - }; - } - - // 使用 extractApiData 统一处理响应数据 - const extractedData = extractApiData(response.data); - - if (extractedData) { - // 确保每个组都有 created_at 和 updated_at 字段 - const currentTime = new Date().toISOString(); - const formattedGroups = extractedData.map(group => ({ - ...group, - created_at: group.created_at || currentTime, - updated_at: group.updated_at || currentTime - })); - - return { data: formattedGroups }; - } else { - return { - error: '获取评查点组数据失败: 返回数据为空', - status: 404 - }; - } - } catch (error) { - console.error('获取评查点组数据失败:', error); - return { - error: error instanceof Error ? error.message : '获取评查点组数据失败', - status: 500 - }; - } -} // 用于评查点输入的接口 interface EvaluationPointInput { @@ -1323,211 +905,6 @@ interface EvaluationPointInput { score?: number; } -/** - * 保存评查点数据 - * @param evaluationPoint 评查点数据 - * @param isEditMode 是否为编辑模式 - * @returns 保存结果 - */ -export async function saveEvaluationPoint(evaluationPoint: EvaluationPointInput, isEditMode: boolean): Promise<{ - data?: FormattedEvaluationPoint[]; - error?: string; - status?: number; -}> { - try { - // console.log(`${isEditMode ? '更新' : '创建'}评查点数据`); - - // 创建一个符合数据库模式的数据副本 - const cleanedData = { - id: evaluationPoint.id, - name: evaluationPoint.name?.trim(), - code: evaluationPoint.code?.trim(), - risk: evaluationPoint.risk || 'low', - is_enabled: evaluationPoint.is_enabled !== undefined ? evaluationPoint.is_enabled : true, - description: evaluationPoint.description || '', - references_laws: evaluationPoint.references_laws || null, - evaluation_point_groups_pid: evaluationPoint.evaluation_point_groups_pid || null, - evaluation_point_groups_id: evaluationPoint.evaluation_point_groups_id || null, - extraction_config: { - llm: { - fields: Array.isArray(evaluationPoint.extraction_config?.llm?.fields) ? - [...evaluationPoint.extraction_config.llm.fields] : [], - prompt_setting: { - type: evaluationPoint.extraction_config?.llm?.prompt_setting?.type || 'system', - template: evaluationPoint.extraction_config?.llm?.prompt_setting?.template || '' - } - }, - vlm: { - fields: Array.isArray(evaluationPoint.extraction_config?.vlm?.fields) ? - [...evaluationPoint.extraction_config.vlm.fields] : [], - prompt_setting: { - type: evaluationPoint.extraction_config?.vlm?.prompt_setting?.type || 'system', - template: evaluationPoint.extraction_config?.vlm?.prompt_setting?.template || '' - } - }, - regex: { - fields: Array.isArray(evaluationPoint.extraction_config?.regex?.fields) ? - [...evaluationPoint.extraction_config.regex.fields] : [] - } - }, - evaluation_config: { - logicType: evaluationPoint.evaluation_config?.logicType || 'and', - customLogic: evaluationPoint.evaluation_config?.customLogic || '', - rules: Array.isArray(evaluationPoint.evaluation_config?.rules) ? - evaluationPoint.evaluation_config.rules.map((rule) => ({ - id: rule.id || '1', - type: rule.type || '', - config: rule.config || {} - })) : [] - }, - pass_message: evaluationPoint.pass_message || '文档检查通过,符合规范要求。', - fail_message: evaluationPoint.fail_message || '文档存在以下问题,请修改后重新提交。', - suggestion_message: evaluationPoint.suggestion_message || '', - suggestion_message_type: evaluationPoint.suggestion_message_type || 'warning', - post_action: evaluationPoint.post_action || 'none', - action_config: evaluationPoint.action_config || '', - score: evaluationPoint.score !== undefined ? Number(evaluationPoint.score) : 0 - }; - - // 如果是新建模式,则删除id字段 - if (!isEditMode) { - delete cleanedData.id; - } - - // 确保配置对象中的规则配置被正确处理 - if (cleanedData.evaluation_config && Array.isArray(cleanedData.evaluation_config.rules)) { - cleanedData.evaluation_config.rules = cleanedData.evaluation_config.rules - .filter(rule => rule && rule.type) // 确保规则有类型 - .map(rule => { - // 根据规则类型确保config中有必要的字段 - const config = { ...rule.config }; - - // 移除辅助字段(同rules.new.tsx中的逻辑) - switch (rule.type) { - case 'exists': - if (!Array.isArray(config.fields)) config.fields = []; - if (!config.logic) config.logic = 'and'; - delete config.availableFields; - delete config.selectedFields; - delete config.existsLogic; - break; - - case 'consistency': - if (!Array.isArray(config.pairs)) config.pairs = []; - if (!config.logic) config.logic = 'and'; - delete config.availableFields; - delete config.logicRelation; - delete config.initialSourceField; - delete config.initialTargetField; - delete config.initialCompareMethod; - break; - - case 'format': - if (!config.field) config.field = ''; - if (!config.formatType) config.formatType = 'date'; - if (!config.parameters) config.parameters = ''; - delete config.availableFields; - delete config.checkField; - delete config.formatParams; - break; - - case 'logic': - if (!Array.isArray(config.conditions)) config.conditions = []; - if (!config.logic) config.logic = 'and'; - delete config.availableFields; - delete config.logicRelation; - delete config.initialField; - delete config.initialOperator; - delete config.initialValue; - break; - - case 'regex': - if (!config.field) config.field = ''; - if (!config.pattern) config.pattern = ''; - if (!config.matchType) config.matchType = 'match'; - delete config.availableFields; - delete config.checkField; - delete config.regexPattern; - break; - - case 'ai': - if (!config.model) config.model = 'qwen14b'; - if (typeof config.temperature !== 'number') config.temperature = 0.1; - if (!config.prompt) config.prompt = ''; - delete config.availableFields; - break; - - case 'code': - if (!config.language) config.language = 'javascript'; - if (!config.code) config.code = ''; - delete config.availableFields; - break; - } - - return { - id: rule.id, - type: rule.type, - config - }; - }); - } - - // console.log("准备发送到API的数据大小:", JSON.stringify(cleanedData).length, "字节"); - - // 使用 postgrest-client 替代直接 fetch 调用 - let response; - - if (isEditMode) { - // 更新操作 - response = await postgrestPut<{code: number; msg: string; data: ApiRule} | ApiRule, typeof cleanedData>( - `/api/postgrest/proxy/evaluation_points`, - cleanedData, - {id: cleanedData.id!} - ); - } else { - // 创建操作 - response = await postgrestPost<{code: number; msg: string; data: ApiRule} | ApiRule, typeof cleanedData>( - '/api/postgrest/proxy/evaluation_points', - cleanedData - ); - } - - // 处理错误响应 - if (response.error) { - return { - error: response.error, - status: response.status - }; - } - - // 使用 extractApiData 统一处理响应数据 - const extractedData = extractApiData(response.data); - - if (extractedData) { - // 转换数据格式后返回 - if (Array.isArray(extractedData)) { - return { - data: extractedData.map(rule => convertApiRuleToFormData(rule)) - }; - } else { - return { - data: [convertApiRuleToFormData(extractedData)] - }; - } - } else { - return { - error: `${isEditMode ? '更新' : '创建'}评查点数据失败: 返回数据为空`, - status: 404 - }; - } - } catch (error) { - console.error("保存评查点失败:", error); - return { - error: error instanceof Error ? error.message : '保存评查点失败', - status: 500 - }; - } -} /** * 评查点统计信息 @@ -1548,129 +925,6 @@ export interface RuleStatistics { }>; } -/** - * 获取评查点统计信息 - * @param token JWT token (可选) - * @returns 评查点统计数据 - */ -export async function getRuleStatistics(token?: string): Promise<{data: RuleStatistics; error?: never} | {data?: never; error: string; status?: number}> { - try { - // 1. 获取所有评查点基本数据(不需要分页) - const postgrestParams: PostgrestParams = { - select: 'id,is_enabled,risk,evaluation_point_groups_id', - token - }; - - const response = await postgrestGet<{code: number; msg: string; data: Array<{ - id: number; - is_enabled: boolean; - risk: string; - evaluation_point_groups_id: number | null; - }>}>('/api/postgrest/proxy/evaluation_points', postgrestParams); - - // 检查是否有错误响应 - if (response.error) { - return { error: response.error, status: response.status }; - } - - // 提取数据 - let evaluationPoints: Array<{ - id: number; - is_enabled: boolean; - risk: string; - evaluation_point_groups_id: number | null; - }> = []; - - if (response.data && 'code' in response.data && response.data.data) { - if (Array.isArray(response.data.data)) { - evaluationPoints = response.data.data; - } - } else if (Array.isArray(response.data)) { - evaluationPoints = response.data; - } - - // 2. 计算基础统计 - const totalCount = evaluationPoints.length; - const enabledCount = evaluationPoints.filter(p => p.is_enabled).length; - const disabledCount = totalCount - enabledCount; - - // 3. 按风险等级统计 - const byRisk = { - low: evaluationPoints.filter(p => p.risk === '低').length, - medium: evaluationPoints.filter(p => p.risk === '中').length, - high: evaluationPoints.filter(p => p.risk === '高').length - }; - - // 4. 按规则组统计 - const groupCountMap = new Map(); - evaluationPoints.forEach(point => { - if (point.evaluation_point_groups_id !== null) { - const currentCount = groupCountMap.get(point.evaluation_point_groups_id) || 0; - groupCountMap.set(point.evaluation_point_groups_id, currentCount + 1); - } - }); - - // 5. 获取规则组名称 - const groupIds = Array.from(groupCountMap.keys()); - const byGroup: Array<{ - group_id: number; - group_name: string; - count: number; - }> = []; - - if (groupIds.length > 0) { - // 批量查询规则组信息 - const groupsParams: PostgrestParams = { - select: 'id,name', - filter: { - 'id': `in.(${groupIds.join(',')})` - }, - token - }; - - const groupsResponse = await postgrestGet<{code: number; msg: string; data: Array<{id: number; name: string}>}>('/api/postgrest/proxy/evaluation_point_groups', groupsParams); - - let groups: Array<{id: number; name: string}> = []; - if (groupsResponse.data && 'code' in groupsResponse.data && groupsResponse.data.data) { - if (Array.isArray(groupsResponse.data.data)) { - groups = groupsResponse.data.data; - } - } else if (Array.isArray(groupsResponse.data)) { - groups = groupsResponse.data; - } - - // 组合统计数据 - groups.forEach(group => { - byGroup.push({ - group_id: group.id, - group_name: group.name, - count: groupCountMap.get(group.id) || 0 - }); - }); - - // 按数量降序排序 - byGroup.sort((a, b) => b.count - a.count); - } - - // 返回统计结果 - const statistics: RuleStatistics = { - total_count: totalCount, - enabled_count: enabledCount, - disabled_count: disabledCount, - by_risk: byRisk, - by_group: byGroup - }; - - return { data: statistics }; - - } catch (error) { - console.error('获取评查点统计信息失败:', error); - return { - error: error instanceof Error ? error.message : '获取评查点统计信息失败', - status: 500 - }; - } -} /** * 批量更新评查点启用状态 @@ -1696,13 +950,6 @@ export async function batchUpdateRuleStatus( // 逐个验证并更新 for (const id of ids) { try { - // 验证评查点是否存在 - const existingRule = await getRule(id, token); - if (existingRule.error || !existingRule.data) { - failedIds.push(id); - errors.push({ id, error: '评查点不存在' }); - continue; - } // 执行更新 const updateResult = await updateRule(id, { isActive: is_enabled }, token); diff --git a/app/api/files/documents.ts b/app/api/files/documents.ts index ac7e95e..f0ed904 100644 --- a/app/api/files/documents.ts +++ b/app/api/files/documents.ts @@ -2,6 +2,7 @@ import { postgrestGet, postgrestDelete, postgrestPut, postgrestPost } from '../p import { getDocumentTypes } from '../document-types/document-types'; import { formatDate } from '../../utils'; import { API_BASE_URL } from '~/config/api-config'; +import type { DocumentType } from './files-upload'; /** * 从不同格式的 API 响应中提取数据 @@ -76,10 +77,10 @@ export interface DocumentUI { pageCount?: number; ocrResult?: unknown; // 结果统计字段 - pass_count: number | null; // 通过数量 - warning_count: number | null; // 警告数量 - error_count: number | null; // 错误数量 - manual_count: number | null; // 人工审核数量 + pass_count?: number | null; // 通过数量 + warning_count?: number | null; // 警告数量 + error_count?: number | null; // 错误数量 + manual_count?: number | null; // 人工审核数量 // 消息详情字段 warning_messages?: string[]; // 警告消息列表 error_messages?: string[]; // 错误消息列表 @@ -336,54 +337,98 @@ export async function getDocument(id: string, userId: string, frontendJWT?: stri * @param id 文档ID * @returns 文档详情 */ -export async function getDocumentWithNoUserId(id: string, frontendJWT?: string): Promise<{ - data?: DocumentUI; +// export async function getDocumentWithNoUserId(id: string, frontendJWT?: string): Promise<{ +// data?: DocumentUI; +// error?: string; +// status?: number; +// }> { +// try { +// if (!id) { +// return { error: '文档ID不能为空', status: 400 }; +// } + +// // console.log("get单个文档id", id) + +// const response = await postgrestGet( +// '/api/postgrest/proxy/documents', +// { +// filter: { +// 'id': `eq.${id}`, +// }, +// limit: 1, +// token: frontendJWT +// } +// ); + +// if (response.error) { +// return { error: response.error, status: response.status }; +// } + +// // console.log("respose", response) +// const extractedData = extractApiData(response.data); +// if (!extractedData || extractedData.length === 0) { +// return { error: '文档不存在', status: 404 }; +// } + +// // console.log('extractedData', extractedData); +// const documentUI = await convertToUIDocument(extractedData[0], frontendJWT); + +// return { data: documentUI }; +// } catch (error) { +// console.error('获取文档详情失败:', error); +// return { +// error: error instanceof Error ? error.message : '获取文档详情失败', +// status: 500 +// }; +// } +// } + + + +/** + * 获取文档类型列表(按IDs过滤版本) + * @param ids 文档类型ID数组(必填) + * @param frontendJWT JWT token(可选) + * @returns 文档类型列表 + */ +export async function getDocumentTypesByIds(ids: number[], frontendJWT?: string): Promise<{ + data?: { types: DocumentType[], total: number }; error?: string; status?: number; }> { try { - if (!id) { - return { error: '文档ID不能为空', status: 400 }; + if (!ids || ids.length === 0) { + return { data: { types: [], total: 0 } }; } - // console.log("get单个文档id", id) - - const response = await postgrestGet( - '/api/postgrest/proxy/documents', + const response = await postgrestGet( + '/api/postgrest/proxy/document_types', { filter: { - 'id': `eq.${id}`, + 'id': `in.(${ids.join(',')})` }, - limit: 1, token: frontendJWT - } - ); - + }); + if (response.error) { return { error: response.error, status: response.status }; } - - // console.log("respose", response) - const extractedData = extractApiData(response.data); - if (!extractedData || extractedData.length === 0) { - return { error: '文档不存在', status: 404 }; + + const extractedData = extractApiData(response.data); + if (!extractedData) { + return { error: '获取文档类型列表失败', status: 500 }; } - - // console.log('extractedData', extractedData); - const documentUI = await convertToUIDocument(extractedData[0], frontendJWT); - - return { data: documentUI }; + + return { data: { types: extractedData, total: extractedData.length } }; } catch (error) { - console.error('获取文档详情失败:', error); + console.error('获取文档类型列表失败:', error); return { - error: error instanceof Error ? error.message : '获取文档详情失败', + error: error instanceof Error ? error.message : '获取文档类型列表失败', status: 500 }; } } - - /** * 更新文档信息 * @param id 文档ID diff --git a/app/api/user/user-management.ts b/app/api/user/user-management.ts index 7fd1fd4..2680461 100644 --- a/app/api/user/user-management.ts +++ b/app/api/user/user-management.ts @@ -58,7 +58,7 @@ export async function getOrganizationTree(includeUsers: boolean = true, jwtToken if (jwtToken) { // 如果提供了JWT Token,则使用axios并携带Authorization头 - const url = `${API_BASE_URL}/admin/users/organizations?include_users=${includeUsers}`; + const url = `${API_BASE_URL}/api/v2/users/organizations?include_users=${includeUsers}`; const response = await axios.get(url, { headers: { 'Authorization': `Bearer ${jwtToken}`, @@ -67,7 +67,8 @@ export async function getOrganizationTree(includeUsers: boolean = true, jwtToken }); responseData = response.data; - } else { + } + else { // 否则,使用原有的get方法 const response = await get( `/admin/users/organizations?include_users=${includeUsers}` diff --git a/app/root.tsx b/app/root.tsx index df3f35a..d8f96f0 100644 --- a/app/root.tsx +++ b/app/root.tsx @@ -229,7 +229,7 @@ export async function loader({ request }: LoaderFunctionArgs) { if (routesResult.success && routesResult.data) { // 从菜单数据中提取所有允许的路径 allowedPaths = extractAllPaths(routesResult.data); - console.log("🔑 [Root Loader] 用户允许的路由:", allowedPaths); + // console.log("🔑 [Root Loader] 用户允许的路由:", allowedPaths); // ✅ 保存权限映射表 if (routesResult.permissionMap) { @@ -238,7 +238,7 @@ export async function loader({ request }: LoaderFunctionArgs) { } // 检查当前路径是否在允许列表中 - console.log("🔑 [Root Loader] 检查当前路径是否在允许列表中...", pathname, allowedPaths); + // console.log("🔑 [Root Loader] 检查当前路径是否在允许列表中...", pathname, allowedPaths); const isAllowedPath = isPathAllowed(pathname, allowedPaths); if (!isAllowedPath) { diff --git a/app/routes/cross-checking._index.tsx b/app/routes/cross-checking._index.tsx index 7cbc911..c7e0030 100644 --- a/app/routes/cross-checking._index.tsx +++ b/app/routes/cross-checking._index.tsx @@ -91,8 +91,15 @@ export async function loader({ request }: LoaderFunctionArgs) { if (!tasksResponse.success) { console.error('获取任务列表失败:', tasksResponse.error); return Response.json({ - error: tasksResponse.error || '获取任务列表失败', - status: 500 + tasks: [], + totalCount: 0, + currentPage: params.page, + pageSize: params.pageSize, + totalPages: 0, + stats: { totalTasks: 0, pendingTasks: 0, inProgressTasks: 0, completedTasks: 0 }, + frontendJWT, + documentTypes: [], + error: tasksResponse.error || '获取任务列表失败' }, { status: 500 }); } @@ -119,8 +126,15 @@ export async function loader({ request }: LoaderFunctionArgs) { } catch (error) { console.error('加载交叉评查任务列表失败:', error); return Response.json({ - error: error || '加载任务列表失败', - status: 500 + tasks: [], + totalCount: 0, + currentPage: params.page, + pageSize: params.pageSize, + totalPages: 0, + stats: { totalTasks: 0, pendingTasks: 0, inProgressTasks: 0, completedTasks: 0 }, + frontendJWT: undefined, + documentTypes: [], + error: error instanceof Error ? error.message : '加载任务列表失败' }, { status: 500 }); } } @@ -219,7 +233,16 @@ const docTypeConfig = { export default function CrossCheckingIndex() { const loaderData = useLoaderData(); - const { tasks, totalCount, currentPage, pageSize, stats, frontendJWT, documentTypes, documentTypesError } = loaderData; + const { + tasks = [], + totalCount = 0, + currentPage = 1, + pageSize = 10, + stats = { totalTasks: 0, pendingTasks: 0, inProgressTasks: 0, completedTasks: 0 }, + frontendJWT, + documentTypes = [], + documentTypesError + } = loaderData || {}; const [searchParams, setSearchParams] = useSearchParams(); const dateFrom = searchParams.get('dateFrom') || ''; const dateTo = searchParams.get('dateTo') || ''; diff --git a/app/routes/cross-checking.result.tsx b/app/routes/cross-checking.result.tsx index 2f97c7f..bd98f7f 100644 --- a/app/routes/cross-checking.result.tsx +++ b/app/routes/cross-checking.result.tsx @@ -642,7 +642,7 @@ export default function CrossCheckingResult() { console.log('[完成评查] 用户点击确认,开始更新状态'); setIsLoading(true); try { - const res = await confirmReviewResults(document.id, jwtToken); + const res = await confirmReviewResults(taskId, document.id, jwtToken); if (res.error) { toastService.error(res.error); diff --git a/app/routes/documents.list.tsx b/app/routes/documents.list.tsx index f8ebde2..3fafa17 100644 --- a/app/routes/documents.list.tsx +++ b/app/routes/documents.list.tsx @@ -11,10 +11,9 @@ import { FilterPanel, FilterSelect, SearchFilter, DateRangeFilter } from "~/comp import { TableRowSkeleton, LoadingIndicator, NumberSkeleton } from '~/components/ui/SkeletonScreen'; import documentsIndexStyles from "~/styles/pages/documents_index.css?url"; import documentVersionStyles from "~/styles/components/document-version.css?url"; -import { deleteDocument, getDocumentsListFromAPI, type DocumentUI, type DocumentVersionUI } from "~/api/files/documents"; +import { getDocumentTypesByIds, deleteDocument, getDocumentsListFromAPI, type DocumentUI, type DocumentVersionUI } from "~/api/files/documents"; // import { IssuesDiff } from "~/components/ui/IssuesDiff"; import { ResultStats } from "~/components/ui/ResultStats"; -import { getDocumentTypes } from "~/api/document-types/document-types"; import { updateDocumentAuditStatus } from "~/api/evaluation_points/rules-files"; import { appendContractAttachments, uploadContractTemplate } from "~/api/files/files-upload"; import { toastService } from "~/components/ui/Toast"; @@ -50,12 +49,12 @@ export const loader = async ({ request }: LoaderFunctionArgs) => { const pageSize = parseInt(url.searchParams.get("pageSize") || "10", 10); // 获取文档类型列表(服务端需要显式传递 token,客户端依赖 axios 拦截器) - const typesResponse = await getDocumentTypes({ pageSize: 500 }, frontendJWT); - const documentTypes = typesResponse.data?.types || []; - const documentTypeOptions = documentTypes.map(type => ({ - value: type.id, - label: type.name - })); + // const typesResponse = await getDocumentTypes(frontendJWT); + // const documentTypes = typesResponse.data?.types || []; + // const documentTypeOptions = documentTypes.map(type => ({ + // value: type.id, + // label: type.name + // })); // 初始返回空数据,将在客户端根据 sessionStorage 中的 documentTypeIds 加载实际数据 return Response.json({ @@ -63,7 +62,7 @@ export const loader = async ({ request }: LoaderFunctionArgs) => { total: 0, page, pageSize, - documentTypeOptions, + documentTypeOptions: [], userInfo, // 传递用户信息到客户端 frontendJWT, // 传递 JWT 到客户端 initialLoad: true // 标记这是初始加载 @@ -190,6 +189,8 @@ export default function DocumentsIndex() { // 添加页面加载状态管理 const [isLoadingData, setIsLoadingData] = useState(true); + // 是否已完成初始化(区分"还没开始加载"和"加载完成但没有数据") + const [hasInitialized, setHasInitialized] = useState(false); const [documents, setDocuments] = useState([]); const [total, setTotal] = useState(0); const [filteredDocumentTypeOptions, setFilteredDocumentTypeOptions] = useState(loaderData.documentTypeOptions); @@ -314,10 +315,7 @@ export default function DocumentsIndex() { setTotal(result.data.total); // 获取经过过滤的文档类型列表 - const filteredTypesResponse = await getDocumentTypes({ - pageSize: 500, - documentTypeIds: typeIds - }, jwtToken); + const filteredTypesResponse = await getDocumentTypesByIds(typeIds, jwtToken); const filteredDocumentTypes = filteredTypesResponse.data?.types || []; const filteredOptions = filteredDocumentTypes.map(type => ({ value: type.id, @@ -330,6 +328,7 @@ export default function DocumentsIndex() { toastService.error('获取文档列表失败: ' + (error instanceof Error ? error.message : '未知错误')); } finally { setIsLoadingData(false); + setHasInitialized(true); // 标记初始化完成 loadingBarService.hide(); } }, [search, documentNumber, documentType, auditStatus, fileStatus, dateFrom, dateTo, currentPage, pageSize, loaderData.frontendJWT]); @@ -344,14 +343,22 @@ export default function DocumentsIndex() { console.log('📋 [useEffect] 从 sessionStorage 获取文档类型IDs:', typeIds); setDocumentTypeIds(typeIds); - // 加载数据 + // 加载数据(fetchData 中会自动获取并设置过滤后的文档类型选项) fetchData(typeIds); } else { console.warn('⚠️ [useEffect] sessionStorage 中没有 documentTypeIds'); + // 没有 documentTypeIds 时,标记初始化完成但无数据 + setIsLoadingData(false); + setHasInitialized(true); + loadingBarService.hide(); } } } catch (error) { console.error('❌ [useEffect] 获取 sessionStorage 中的 documentTypeIds 失败:', error); + // 出错时也标记初始化完成 + setIsLoadingData(false); + setHasInitialized(true); + loadingBarService.hide(); } }, [fetchData]); @@ -394,23 +401,16 @@ export default function DocumentsIndex() { } }, [documents, expandedRows]); - // 使用并更新缓存数据 + // 更新缓存数据并处理 loader 错误 useEffect(() => { - // 如果有缓存数据,先显示缓存,再在后台加载新数据 - if (dataCache.current) { - setIsLoadingData(false); - } else { - // 显示加载状态 - 确保显示加载条 - loadingBarService.show(); - setIsLoadingData(true); - } - - // 设置缓存数据 + // 设置缓存数据(用于后续可能的优化) dataCache.current = loaderData; - + // 处理loader错误 if (loaderData.error) { toastService.error(loaderData.error); + setIsLoadingData(false); + setHasInitialized(true); } }, [loaderData]); @@ -1490,7 +1490,7 @@ export default function DocumentsIndex() {

文档列表

- {isLoadingData ? ( + {!hasInitialized || isLoadingData ? (
@@ -1622,11 +1622,12 @@ export default function DocumentsIndex() {
- {isLoadingData && documents.length === 0 ? ( + {/* 未初始化完成时显示骨架屏,初始化完成后根据数据显示内容或"暂无数据" */} + {!hasInitialized || (isLoadingData && documents.length === 0) ? ( ) : documents.length === 0 ? (
- {isLoadingData ? "加载中..." : "暂无数据"} + 暂无数据
) : ( diff --git a/app/routes/rules.new.tsx b/app/routes/rules.new.tsx index c71718c..67256df 100644 --- a/app/routes/rules.new.tsx +++ b/app/routes/rules.new.tsx @@ -853,11 +853,43 @@ export default function RuleNew() { setIsLoading(false); } else if (response.data) { // 获取新创建或更新的评查点ID - const savedPointId = response.data.id; + let savedPointId: number | undefined; + let successMessage = ''; + + if (isEditMode) { + // 编辑模式:直接从 response.data.id 获取 + savedPointId = response.data.id; + successMessage = '评查点更新成功!'; + } else { + // 创建模式:从 items 数组中找到 code 不包含 '--' 后缀的基础评查点 + const responseData = response.data as { + success?: boolean; + total_created?: number; + message?: string; + items?: Array<{ id: number; code: string; [key: string]: unknown }>; + }; + + if (responseData.items && Array.isArray(responseData.items) && responseData.items.length > 0) { + // 查找 code 不包含 '--' 的评查点(基础评查点) + const baseItem = responseData.items.find(item => !item.code.includes('--')); + if (baseItem) { + savedPointId = baseItem.id; + } else { + // 如果所有 code 都包含 '--',取第一个 + savedPointId = responseData.items[0].id; + } + // 使用后端返回的消息,或生成默认消息 + successMessage = responseData.message || `评查点创建成功! 共创建 ${responseData.total_created || responseData.items.length} 个地区的评查点`; + } else if (response.data.id) { + // 兼容旧格式:直接返回单个评查点 + savedPointId = response.data.id; + successMessage = '评查点创建成功!'; + } + } if (savedPointId) { // 显示成功消息 - toastService.success(`评查点${isEditMode ? '更新' : '创建'}成功!`); + toastService.success(successMessage); // 保存成功后跳转到编辑页面并重新加载数据 navigate(`/rules/new?id=${savedPointId}`, { replace: true }); diff --git a/auth_doc/交叉评查接口对接状态报告.md b/auth_doc/交叉评查接口对接状态报告.md new file mode 100644 index 0000000..83785de --- /dev/null +++ b/auth_doc/交叉评查接口对接状态报告.md @@ -0,0 +1,326 @@ +# 交叉评查接口对接状态报告 + +> 本文档对比 `auth_doc/交叉评查接口文档(1).md` 中定义的10个接口与前端代码实际调用的接口情况。 +> +> 生成时间:2025-12-11 + +--- + +## 一、接口总览对比 + +### 文档定义的10个接口 + +| 序号 | 方法 | 文档路径 | 接口名称 | +|------|------|----------|----------| +| 1 | `POST` | `/api/v2/cross_review/proposals` | 发起评分提案 | +| 2 | `POST` | `/api/v2/cross_review/proposals/{proposal_id}/votes` | 对提案投票 | +| 3 | `DELETE` | `/api/v2/cross_review/proposals/{proposal_id}` | 撤销评分提案 | +| 4 | `POST` | `/api/v2/cross_review/proposals/details` | 获取提案列表及详情 | +| 5 | `POST` | `/api/v2/cross_review/proposals/document` | 获取指定文档的提案列表 | +| 6 | `POST` | `/api/v2/cross_review/proposals/document/check_pending_votes` | 检查未投票用户 | +| 7 | `POST` | `/api/v2/cross_review/tasks/user_tasks` | 获取用户参与的任务列表 | +| 8 | `GET` | `/api/v2/cross_review/tasks/{task_id}/progress` | 获取评查任务进度 | +| 9 | `POST` | `/api/v2/cross_review/tasks/{task_id}/documents` | 获取任务下文档列表 | +| 10 | `POST` | `/api/v2/cross_review/tasks/{task_id}/documents/{document_id}/complete` | 确认完成文档评查 | + +--- + +## 二、前端代码调用的接口清单 + +### 涉及文件 + +1. `app/api/cross-checking/cross-file-result.ts` - 提案/意见相关操作 +2. `app/api/cross-checking/cross-files.ts` - 任务列表相关操作 +3. `app/api/cross-checking/cross-files-upload.ts` - 文件上传相关操作 + +--- + +## 三、逐一对比分析 + +### ✅ 接口 1:发起评分提案 + +| 项目 | 文档定义 | 前端实现 | 状态 | +|------|----------|----------|------| +| **方法** | `POST` | `POST` | ✅ 一致 | +| **路径** | `/api/v2/cross_review/proposals` | `/admin/cross_review/proposals` | ⚠️ **差异** | +| **文件** | - | `cross-file-result.ts:139` | - | +| **函数** | - | `submitCrossCheckingOpinion()` | - | + +**差异说明**: +- 前端使用 `/admin/cross_review/proposals`,缺少 `/v2` 版本号 +- 文档推荐使用 `/api/v2/cross_review/proposals` + +**请求参数对比**: +| 参数 | 文档要求 | 前端发送 | 状态 | +|------|----------|----------|------| +| `document_id` | int, 必填 | ✅ 发送 | ✅ | +| `evaluation_point_id` | int, 必填 | ✅ 发送 | ✅ | +| `proposed_score` | float, 必填 | ✅ 发送 | ✅ | +| `reason` | string, 必填 | ✅ 发送 | ✅ | +| `proposer_id` | int, 必填 | ✅ 发送 | ✅ | +| `evaluation_result_id` | int, 必填 | ✅ 发送 | ✅ | + +--- + +### ✅ 接口 2:对提案投票 + +| 项目 | 文档定义 | 前端实现 | 状态 | +|------|----------|----------|------| +| **方法** | `POST` | `POST` | ✅ 一致 | +| **路径** | `/api/v2/cross_review/proposals/{proposal_id}/votes` | `/admin/cross_review/proposals/{opinionId}/votes` | ⚠️ **差异** | +| **文件** | - | `cross-file-result.ts:321-333` | - | +| **函数** | - | `performOpinionAction()` (agree/disagree/withdraw_vote) | - | + +**差异说明**: +- 前端使用 `/admin/cross_review/proposals/{id}/votes`,缺少 `/v2` 版本号 + +**请求参数对比**: +| 参数 | 文档要求 | 前端发送 | 状态 | +|------|----------|----------|------| +| `vote_type` | string (agree/disagree/cancel), 必填 | ✅ 发送 | ✅ | +| `voter_id` | int, 必填 | ✅ 发送 | ✅ | + +--- + +### ✅ 接口 3:撤销评分提案 + +| 项目 | 文档定义 | 前端实现 | 状态 | +|------|----------|----------|------| +| **方法** | `DELETE` | `DELETE` | ✅ 一致 | +| **路径** | `/api/v2/cross_review/proposals/{proposal_id}` | `/admin/cross_review/proposals/{opinionId}` | ⚠️ **差异** | +| **文件** | - | `cross-file-result.ts:338` | - | +| **函数** | - | `performOpinionAction()` (withdraw_opinion) | - | + +**差异说明**: +- 前端使用 `/admin/cross_review/proposals/{id}`,缺少 `/v2` 版本号 + +--- + +### ❌ 接口 4:获取提案列表及详情 + +| 项目 | 文档定义 | 前端实现 | 状态 | +|------|----------|----------|------| +| **方法** | `POST` | - | ❌ **未实现** | +| **路径** | `/api/v2/cross_review/proposals/details` | - | - | +| **说明** | 获取当前用户需要处理的所有待投票提案列表 | - | - | + +**备注**:此接口用于获取用户需要投票的待处理提案,前端目前未调用此接口。 + +--- + +### ✅ 接口 5:获取指定文档的提案列表 + +| 项目 | 文档定义 | 前端实现 | 状态 | +|------|----------|----------|------| +| **方法** | `POST` | `POST` | ✅ 一致 | +| **路径** | `/api/v2/cross_review/proposals/document` | `/admin/cross_review/proposals/document` | ⚠️ **差异** | +| **文件** | - | `cross-file-result.ts:199` | - | +| **函数** | - | `getCrossCheckingOpinions()` | - | + +**差异说明**: +- 前端使用 `/admin/cross_review/proposals/document`,缺少 `/v2` 版本号 + +**请求参数对比**: +| 参数 | 文档要求 | 前端发送 | 状态 | +|------|----------|----------|------| +| `document_id` | int, 必填 | ✅ 发送 | ✅ | +| `page` | int, 选填, 默认1 | ✅ 发送 | ✅ | +| `page_size` | int, 选填, 默认10 | ✅ 发送 | ✅ | +| `user_id` | - (文档未要求) | ⚠️ 发送 | ⚠️ 多余参数 | + +--- + +### ✅ 接口 6:检查未投票用户 + +| 项目 | 文档定义 | 前端实现 | 状态 | +|------|----------|----------|------| +| **方法** | `POST` | `POST` | ✅ 一致 | +| **路径** | `/api/v2/cross_review/proposals/document/check_pending_votes` | `/admin/cross_review/proposals/document/check_pending_votes` | ⚠️ **差异** | +| **文件** | - | `cross-file-result.ts:474` | - | +| **函数** | - | `checkProposalVotes()` | - | + +**差异说明**: +- 前端使用 `/admin/cross_review/proposals/document/check_pending_votes`,缺少 `/v2` 版本号 + +**请求参数对比**: +| 参数 | 文档要求 | 前端发送 | 状态 | +|------|----------|----------|------| +| `document_id` | int, 必填 | ✅ 发送 | ✅ | + +--- + +### ✅ 接口 7:获取用户参与的任务列表 + +| 项目 | 文档定义 | 前端实现 | 状态 | +|------|----------|----------|------| +| **方法** | `POST` | `POST` | ✅ 一致 | +| **路径** | `/api/v2/cross_review/tasks/user_tasks` | `/admin/v2/cross_review/tasks/user_tasks` | ✅ **正确** | +| **文件** | - | `cross-files.ts:406` | - | +| **函数** | - | `getUserTaskDocuments()` | - | + +**说明**:此接口已正确使用 `/v2` 版本号路径。 + +**请求参数对比**: +| 参数 | 文档要求 | 前端发送 | 状态 | +|------|----------|----------|------| +| `page` | int, 选填, 默认1 | ✅ 发送 | ✅ | +| `page_size` | int, 选填, 默认10 | ✅ 发送 | ✅ | + +--- + +### ❌ 接口 8:获取评查任务进度 + +| 项目 | 文档定义 | 前端实现 | 状态 | +|------|----------|----------|------| +| **方法** | `GET` | - | ❌ **未实现** | +| **路径** | `/api/v2/cross_review/tasks/{task_id}/progress` | - | - | +| **说明** | 根据任务ID获取评查进度详情 | - | - | + +**备注**:前端目前通过 `getUserTaskDocuments` 接口返回的 `progress` 字段获取进度,未单独调用此接口。 + +--- + +### ✅ 接口 9:获取任务下文档列表 + +| 项目 | 文档定义 | 前端实现 | 状态 | +|------|----------|----------|------| +| **方法** | `POST` | `POST` | ✅ 一致 | +| **路径** | `/api/v2/cross_review/tasks/{task_id}/documents` | `/admin/v2/cross_review/tasks/{taskId}/documents` | ✅ **正确** | +| **文件** | - | `cross-files.ts:448` | - | +| **函数** | - | `getTaskDocuments()` | - | + +**说明**:此接口已正确使用 `/v2` 版本号路径。 + +**请求参数对比**: +| 参数 | 文档要求 | 前端发送 | 状态 | +|------|----------|----------|------| +| `page` | int, 选填, 默认1 | ✅ 发送 | ✅ | +| `page_size` | int, 选填, 默认10 | ✅ 发送 | ✅ | +| `file_type_ids` | array[int], 选填 | ❌ 未发送 | ⚠️ 未使用 | +| `date_from` | string, 选填 | ❌ 未发送 | ⚠️ 未使用 | +| `date_to` | string, 选填 | ❌ 未发送 | ⚠️ 未使用 | +| `keyword` | string, 选填 | ❌ 未发送 | ⚠️ 未使用 | +| `order` | string, 选填 | ❌ 未发送 | ⚠️ 未使用 | + +--- + +### ✅ 接口 10:确认完成文档评查 + +| 项目 | 文档定义 | 前端实现 | 状态 | +|------|----------|----------|------| +| **方法** | `POST` | `POST` | ✅ 一致 | +| **路径** | `/api/v2/cross_review/tasks/{task_id}/documents/{document_id}/complete` | `/admin/v2/cross_review/tasks/{task_id}/documents/{document_id}/complete` | ✅ **正确** | +| **文件** | - | `cross-file-result.ts:417` | - | +| **函数** | - | `confirmReviewResults(taskId, documentId, jwtToken)` | - | + +**说明**:此接口已正确使用 `/v2` 版本号路径,并包含 `task_id` 和 `document_id` 参数。 + +**已于 2025-12-11 修复完成。** + +--- + +## 四、其他前端调用但文档未定义的接口 + +### 1. 创建交叉评查任务 (upload相关) + +| 项目 | 前端实现 | +|------|----------| +| **方法** | `POST` | +| **路径** | `/admin/cross_review/tasks/assign` | +| **文件** | `cross-files-upload.ts:316` | +| **函数** | `createCrossReviewTask()` | + +**说明**:此接口用于创建交叉评查任务并分配用户,在本文档10个接口中未定义。 + +### 2. 上传并分配文件 + +| 项目 | 前端实现 | +|------|----------| +| **方法** | `POST` | +| **路径** | `${UPLOAD_URL}/cross_review/documents/upload_and_assign` | +| **文件** | `cross-files-upload.ts:233` | +| **函数** | `batchUploadAndAssignCrossCheckingFiles()` | + +**说明**:此接口用于批量上传文件并分配交叉评查任务,在本文档10个接口中未定义。 + +### 3. PostgREST 直接查询 + +| 项目 | 前端实现 | +|------|----------| +| **路径** | `/api/postgrest/proxy/cross_examination_tasks` | +| **文件** | `cross-file-result.ts:93` | +| **函数** | `findIsProposer()` | + +**说明**:直接通过 PostgREST 查询数据库表,未走统一的 API 接口。 + +### 4. PostgREST 直接查询文档类型 + +| 项目 | 前端实现 | +|------|----------| +| **路径** | `/api/postgrest/proxy/document_types` | +| **文件** | `cross-files.ts:529` | +| **函数** | `getCrossCheckingDocumentTypes()` | + +**说明**:直接通过 PostgREST 查询数据库表获取文档类型。 + +--- + +## 五、总结 + +### 对接状态统计 + +| 状态 | 数量 | 百分比 | +|------|------|--------| +| ✅ 已正确对接 | 3 | 30% | +| ⚠️ 路径差异(缺少v2) | 5 | 50% | +| ❌ 未实现 | 2 | 20% | + +### 需要修复的问题 + +#### 🟡 中优先级(建议修复) + +1. **统一使用 `/api/v2` 或 `/admin/v2` 前缀** + - 接口 1、2、3、5、6 使用了旧路径 `/admin/cross_review/...` + - 建议统一改为 `/admin/v2/cross_review/...` 或 `/api/v2/cross_review/...` + +#### 🟢 低优先级(可选实现) + +3. **接口4:获取提案列表及详情** + - 当前未实现,如需要在其他页面展示待投票提案列表可实现 + +4. **接口8:获取评查任务进度** + - 当前通过任务列表接口获取进度,如需单独获取可实现 + +5. **接口9:增加筛选参数支持** + - `getTaskDocuments()` 未支持 `file_type_ids`、`date_from`、`date_to`、`keyword`、`order` 等筛选参数 + +--- + +## 六、修复建议代码示例 + +### 统一路径前缀 + +建议在 `api-config.ts` 中定义: + +```typescript +// api-config.ts +export const CROSS_REVIEW_API_PREFIX = '/admin/v2/cross_review'; +``` + +然后在各接口中使用: + +```typescript +// cross-file-result.ts +import { API_BASE_URL, CROSS_REVIEW_API_PREFIX } from '../../config/api-config'; + +// 使用示例 +const response = await axios.post( + `${API_BASE_URL}${CROSS_REVIEW_API_PREFIX}/proposals`, + requestData, + { headers: { ... } } +); +``` + +--- + +*报告生成完毕* diff --git a/auth_doc/交叉评查接口文档(1).md b/auth_doc/交叉评查接口文档(1).md new file mode 100644 index 0000000..c1febef --- /dev/null +++ b/auth_doc/交叉评查接口文档(1).md @@ -0,0 +1,1080 @@ +# 交叉评查接口文档 + +> 本文档描述交叉评查模块的所有API接口,供前端直接对接使用。 + +## 基础信息 + +- **基础路径**: `/api/v2/cross_review` (推荐) +- **备用路径**: `/admin/v2/cross_review` (兼容,功能相同) +- **认证方式**: JWT Token (Header: `Authorization: Bearer `) +- **Content-Type**: `application/json` + +> **说明**: `/api/v2` 和 `/admin/v2` 两个前缀指向同一套接口,功能完全相同。建议前端统一使用 `/api/v2/cross_review`。 + +--- + +## 接口总览 + +### 提案管理 + +| 方法 | 路径 | 接口名称 | 权限 | +|------|------|----------|------| +| `POST` | `/proposals` | 发起评分提案 | `cross_review:proposal:create` | +| `DELETE` | `/proposals/{proposal_id}` | 撤销评分提案 | `cross_review:proposal:delete` | +| `POST` | `/proposals/{proposal_id}/votes` | 对提案投票 | `cross_review:proposal:vote` | +| `POST` | `/proposals/details` | 获取提案列表及详情 | `cross_review:proposal:read` | +| `POST` | `/proposals/document` | 获取指定文档的提案列表 | `cross_review:proposal:read` | +| `POST` | `/proposals/document/check_pending_votes` | 检查未投票用户 | `cross_review:task:read` | + +### 任务管理 + +| 方法 | 路径 | 接口名称 | 权限 | +|------|------|----------|------| +| `POST` | `/tasks/user_tasks` | 获取用户参与的任务列表 | `cross_review:task:read` | +| `GET` | `/tasks/{task_id}/progress` | 获取评查任务进度 | `cross_review:progress:view` | +| `POST` | `/tasks/{task_id}/documents` | 获取任务下文档列表 | `cross_review:task:read` | +| `POST` | `/tasks/{task_id}/documents/{document_id}/complete` | 确认完成文档评查 | `cross_review:document:complete` | + +--- + +## 接口详细说明 + +--- + +### 1. 发起评分提案 + +**POST** `/api/v2/cross_review/proposals` + +为某个评查结果创建一个新的评分提案(加分或扣分)。 + +**权限**: `cross_review:proposal:create` + +#### 请求参数 + +| 字段 | 类型 | 必填 | 说明 | +|------|------|------|------| +| `document_id` | int | 是 | 文档ID | +| `evaluation_point_id` | int | 是 | 评查点ID | +| `proposed_score` | float | 是 | 建议加/减分数(正数加分,负数扣分,不能为0) | +| `reason` | string | 是 | 理由说明(不能为空) | +| `proposer_id` | int | 是 | 提案人ID | +| `evaluation_result_id` | int | 是 | 评查结果ID(必填!) | + +#### 请求示例 + +```json +{ + "document_id": 123, + "evaluation_point_id": 456, + "proposed_score": -5, + "reason": "该评查点存在明显问题,应扣5分", + "proposer_id": 1, + "evaluation_result_id": 789 +} +``` + +#### 业务逻辑 + +1. **权限验证** + - 验证用户是否有权限访问该文档 + - 验证 `evaluation_result_id` 是否真正属于 `document_id`(防止IDOR攻击) + +2. **任务参与者验证** + - 查找文档关联的最新交叉评查任务 + - 验证提案人是否是任务的参与者(在 `user_ids` 中) + +3. **重复提案检查** + - 同一用户不能对同一评查点重复创建提案 + +4. **分数校验** + - 不能创建 0 分的提案 + - 当前分数为 0 时,不能发起扣分提案(`proposed_score < 0`) + - 当前分数已满分时,不能发起加分提案(`proposed_score > 0`) + +5. **自动投票** + - 创建提案后,系统自动为提案人创建一条"同意"的投票记录 + +6. **状态检查** + - 检查是否达到通过/否决条件,自动更新提案状态 + +#### 响应示例 + +**成功 (201)**: +```json +{ + "code": 0, + "success": true, + "message": "评分提案创建成功", + "proposal": { + "id": 1, + "document_id": 123, + "evaluation_point_id": 456, + "proposed_score": -5, + "reason": "该评查点存在明显问题,应扣5分", + "proposer_id": 1, + "status": "pending", + "evaluation_result_id": 789, + "created_at": "2024-01-01T10:00:00" + } +} +``` + +#### 错误码 + +| 状态码 | 错误信息 | 说明 | +|--------|----------|------| +| `400` | 评查结果ID不能为空 | `evaluation_result_id` 未提供 | +| `400` | 不能创建0分的提案 | `proposed_score` 为 0 | +| `400` | 当前分数为0,不能再发起扣分提案 | 当前评查结果分数已经是0 | +| `400` | 当前已满分,不能再加分 | 当前评查结果已达到满分 | +| `400` | 您已经为该评查点创建过提案 | 重复创建提案 | +| `400` | 文档未分配任何评查任务 | 文档不在交叉评查任务中 | +| `400` | 用户无权为该文档创建提案 | 用户不是任务参与者 | +| `403` | 无权访问此文档 | 文档权限验证失败 | +| `403` | 评查结果与文档不匹配 | IDOR攻击防护触发 | +| `404` | 评查结果不存在 | `evaluation_result_id` 无效 | + +--- + +### 2. 对提案投票 + +**POST** `/api/v2/cross_review/proposals/{proposal_id}/votes` + +对指定的评分提案进行投票(同意/反对/取消)。 + +**权限**: `cross_review:proposal:vote` + +#### 路径参数 + +| 参数 | 类型 | 说明 | +|------|------|------| +| `proposal_id` | int | 提案ID(必须大于0) | + +#### 请求参数 + +| 字段 | 类型 | 必填 | 说明 | +|------|------|------|------| +| `vote_type` | string | 是 | 投票类型:`agree`(同意) / `disagree`(反对) / `cancel`(取消投票) | +| `voter_id` | int | 是 | 投票人的用户ID | + +#### 请求示例 + +```json +{ + "vote_type": "agree", + "voter_id": 2 +} +``` + +#### 业务逻辑 + +1. **提案状态检查** + - 提案必须存在且未被删除 + - 只能对 `pending` 状态的提案投票 + - 已 `approved` 或 `rejected` 的提案无法投票 + +2. **任务参与者验证** + - 根据提案关联的文档找到对应任务 + - 验证投票人是否是任务参与者 + +3. **分数校验**(投票前再次校验) + - 不能投 0 分的提案 + - 当前分数为 0 时,不能投赞成扣分提案 + - 当前已满分时,不能投赞成加分提案 + +4. **投票处理** + - **新投票**: 创建新的投票记录 + - **更新投票**: 如果已投票,更新投票类型 + - **恢复投票**: 如果之前取消过投票,恢复并更新 + - **取消投票**: 软删除投票记录(设置 `deleted_at`) + +5. **自动状态更新** + - 投票后自动检查提案是否达到通过/否决条件 + - **通过条件**: 同意票数 >= (参与人数/2 + 1) + - **否决条件**: 反对票数 >= (参与人数/2 + 1) 或 剩余票数不足以达到通过条件 + - 提案通过后自动更新评查结果的 `final_score` + +6. **文档完成检查** + - 提案状态变更后,检查文档下所有提案是否都已完成 + - 如果都完成,自动将文档标记为已完成(`audit_status=1`) + +#### 响应示例 + +**成功 (201)**: +```json +{ + "code": 0, + "success": true, + "message": "投票成功", + "proposal_status": "approved" +} +``` + +**取消投票成功**: +```json +{ + "code": 0, + "success": true, + "message": "投票已撤销", + "proposal_status": "pending" +} +``` + +#### 错误码 + +| 状态码 | 错误信息 | 说明 | +|--------|----------|------| +| `400` | 提案不存在或已被删除 | 无效的提案ID | +| `400` | 提案状态为 approved/rejected,无法投票 | 提案已结束 | +| `400` | 不能投0分的提案 | 提案分数为0 | +| `400` | 当前分数为0,不能再扣分 | 评查结果分数已是0 | +| `400` | 当前已满分,不能再加分 | 评查结果已满分 | +| `400` | 用户无权对该提案投票 | 用户不是任务参与者 | + +--- + +### 3. 撤销评分提案 + +**DELETE** `/api/v2/cross_review/proposals/{proposal_id}` + +撤销一个评分提案。仅提案人本人可以撤销。 + +**权限**: `cross_review:proposal:delete` + +#### 路径参数 + +| 参数 | 类型 | 说明 | +|------|------|------| +| `proposal_id` | int | 提案ID(必须大于0) | + +#### 请求体 + +无需请求体。 + +#### 业务逻辑 + +1. **提案存在性验证** + - 验证提案存在且未被删除 + +2. **权限验证** + - 只有提案人本人才能撤销自己的提案 + +3. **状态验证** + - 只能撤销 `pending` 状态的提案 + - 已 `approved` 或 `rejected` 的提案无法撤销 + +4. **软删除** + - 将提案的 `deleted_at` 设置为当前时间 + - 同时软删除所有关联的投票记录 + +#### 响应示例 + +**成功 (200)**: +```json +{ + "code": 0, + "success": true, + "message": "提案已成功撤销" +} +``` + +#### 错误码 + +| 状态码 | 错误信息 | 说明 | +|--------|----------|------| +| `400` | 提案不存在 | 无效的提案ID | +| `400` | 提案状态为 approved/rejected,无法撤销 | 提案已结束 | +| `403` | 只有提案人才能撤销自己的提案 | 权限不足 | + +--- + +### 4. 获取提案列表及详情 + +**POST** `/api/v2/cross_review/proposals/details` + +获取当前用户需要处理的所有待投票提案列表(排除自己创建的提案和已投票的提案)。 + +**权限**: `cross_review:proposal:read` + +#### 请求参数 + +| 字段 | 类型 | 必填 | 默认值 | 说明 | +|------|------|------|--------|------| +| `document_id` | int | 否 | null | 按文档ID过滤(不传则查所有) | +| `page` | int | 否 | 1 | 页码(从1开始) | +| `page_size` | int | 否 | 20 | 每页数量 | + +#### 请求示例 + +```json +{ + "document_id": 123, + "page": 1, + "page_size": 20 +} +``` + +#### 业务逻辑 + +1. **任务范围确定** + - 查找当前用户参与的所有交叉评查任务 + - 获取这些任务下的所有文档ID + +2. **提案过滤** + - 只返回 `pending` 状态的提案 + - 排除当前用户自己创建的提案 + - 如果指定了 `document_id`,只返回该文档的提案 + +3. **关联信息查询** + - 评查点名称 + - 提案人昵称 + - 所有投票人及投票类型 + - 同意者列表、反对者列表 + - 待投票者列表 + - 发现问题(evaluation_result 的 message) + +4. **投票状态判断** + - 判断当前用户是否已对该提案投票 + - 返回 `can_vote` 字段 + +#### 响应示例 + +```json +{ + "data": [ + { + "proposal_id": 1, + "evaluation_point_name": "当事人签名检查", + "proposed_score": -5, + "reason": "缺少当事人签名", + "proposer": "张三", + "votes": [ + {"voter": "张三", "vote_type": "agree"}, + {"voter": "李四", "vote_type": "disagree"} + ], + "agree_voters": ["张三"], + "disagree_voters": ["李四"], + "can_vote": true, + "problem_message": "文档中未找到当事人签名", + "pending_voters": ["王五", "赵六"], + "status": "pending" + } + ], + "pagination": { + "page": 1, + "page_size": 20, + "total": 5, + "total_pages": 1 + } +} +``` + +#### 返回字段说明 + +| 字段 | 类型 | 说明 | +|------|------|------| +| `proposal_id` | int | 提案ID | +| `evaluation_point_name` | string | 评查点名称 | +| `proposed_score` | float | 提议的加/减分数 | +| `reason` | string | 提案理由 | +| `proposer` | string | 提案人昵称 | +| `votes` | array | 所有投票记录(含投票人和投票类型) | +| `agree_voters` | array | 同意者昵称列表 | +| `disagree_voters` | array | 反对者昵称列表 | +| `can_vote` | boolean | 当前用户是否可以投票 | +| `problem_message` | string | 评查结果发现的问题描述 | +| `pending_voters` | array | 待投票者昵称列表 | +| `status` | string | 提案状态(pending/approved/rejected) | + +--- + +### 5. 获取指定文档的提案列表 + +**POST** `/api/v2/cross_review/proposals/document` + +获取指定文档下的所有评分提案及其详细信息(包含所有状态的提案)。 + +**权限**: `cross_review:proposal:read` + +#### 请求参数 + +| 字段 | 类型 | 必填 | 默认值 | 说明 | +|------|------|------|--------|------| +| `document_id` | int | 是 | - | 文档ID | +| `page` | int | 否 | 1 | 页码(从1开始) | +| `page_size` | int | 否 | 10 | 每页数量 | + +#### 请求示例 + +```json +{ + "document_id": 123, + "page": 1, + "page_size": 10 +} +``` + +#### 业务逻辑 + +1. **权限验证** + - 验证当前用户是否有权限访问该文档 + +2. **提案查询** + - 获取该文档下所有未删除的提案(包括 pending/approved/rejected) + +3. **关联信息查询** + - 评查点名称 + - 提案人昵称和ID + - 所有投票人及投票类型 + - 同意者列表、反对者列表 + - 待投票者列表 + - 发现问题 + - 创建时间 + +4. **投票状态判断** + - 判断当前用户是否可以投票 + - 条件:未投票 + 不是提案人 + 是任务参与者 + +#### 响应示例 + +```json +{ + "data": [ + { + "proposal_id": 1, + "evaluation_point_name": "当事人签名检查", + "proposed_score": -5, + "reason": "缺少当事人签名", + "proposer": "张三", + "proposer_id": 1, + "votes": [ + {"voter": "张三", "vote_type": "agree"}, + {"voter": "李四", "vote_type": "agree"}, + {"voter": "王五", "vote_type": "agree"} + ], + "agree_voters": ["张三", "李四", "王五"], + "disagree_voters": [], + "problem_message": "文档中未找到当事人签名", + "pending_voters": [], + "created_at": "2024-01-01 10:00:00", + "can_vote": false, + "status": "approved" + } + ], + "pagination": { + "page": 1, + "page_size": 10, + "total": 3, + "total_pages": 1 + } +} +``` + +#### 返回字段说明 + +| 字段 | 类型 | 说明 | +|------|------|------| +| `proposal_id` | int | 提案ID | +| `evaluation_point_name` | string | 评查点名称 | +| `proposed_score` | float | 提议的加/减分数 | +| `reason` | string | 提案理由 | +| `proposer` | string | 提案人昵称 | +| `proposer_id` | int | 提案人ID | +| `votes` | array | 所有投票记录 | +| `agree_voters` | array | 同意者昵称列表 | +| `disagree_voters` | array | 反对者昵称列表 | +| `problem_message` | string | 评查结果发现的问题描述 | +| `pending_voters` | array | 待投票者昵称列表 | +| `created_at` | string | 创建时间(格式:yyyy-MM-dd HH:mm:ss) | +| `can_vote` | boolean | 当前用户是否可以投票 | +| `status` | string | 提案状态 | + +#### 错误码 + +| 状态码 | 错误信息 | 说明 | +|--------|----------|------| +| `403` | 无权访问此文档 | 用户无权限访问该文档 | + +--- + +### 6. 检查未投票用户 + +**POST** `/api/v2/cross_review/proposals/document/check_pending_votes` + +检查指定文档下所有提案是否存在未投票的用户。**仅任务创建人可调用**。 + +**权限**: `cross_review:task:read` + +#### 请求参数 + +| 字段 | 类型 | 必填 | 说明 | +|------|------|------|------| +| `document_id` | int | 是 | 文档ID | + +#### 请求示例 + +```json +{ + "document_id": 123 +} +``` + +#### 业务逻辑 + +1. **任务查找** + - 根据文档ID查找关联的最新任务 + +2. **权限验证** + - 验证当前用户是否是任务的创建人(`assigner_id`) + - 只有任务创建人才能调用此接口 + +3. **投票统计** + - 查找文档下所有提案 + - 统计每个提案的已投票用户 + - 计算待投票用户(任务参与者 - 已投票用户 - 提案人) + +#### 响应示例 + +**有未投票用户**: +```json +{ + "has_pending_votes": true, + "pending_proposals": [ + { + "proposal_id": 1, + "pending_voters_num": 2, + "pending_voters": ["王五", "赵六"], + "evaluation_point_name": "当事人签名检查" + }, + { + "proposal_id": 2, + "pending_voters_num": 1, + "pending_voters": ["赵六"], + "evaluation_point_name": "日期格式检查" + } + ] +} +``` + +**无未投票用户**: +```json +{ + "has_pending_votes": false, + "pending_proposals": [] +} +``` + +#### 错误码 + +| 状态码 | 错误信息 | 说明 | +|--------|----------|------| +| `400` | 未找到该文档的任务 | 文档不在任何任务中 | +| `403` | 只有任务创建人可以执行此操作 | 当前用户不是任务创建人 | + +--- + +### 7. 获取用户参与的任务列表 + +**POST** `/api/v2/cross_review/tasks/user_tasks` + +获取当前用户参与的所有交叉评查任务列表(分页)。 + +**权限**: `cross_review:task:read` + +#### 请求参数 + +| 字段 | 类型 | 必填 | 默认值 | 说明 | +|------|------|------|--------|------| +| `page` | int | 否 | 1 | 页码(从1开始) | +| `page_size` | int | 否 | 10 | 每页数量 | + +#### 请求示例 + +```json +{ + "page": 1, + "page_size": 10 +} +``` + +#### 业务逻辑 + +1. **任务查询** + - 查询当前用户参与的所有任务(用户ID在任务的 `user_ids` 数组中) + +2. **进度计算** + - 统计每个任务下的文档总数 + - 统计已完成文档数(`cross_task_document_mapping.audit_status = 1`) + - 计算完成百分比 + +3. **文档类型解析** + - 将 `doc_type` 代码转换为可读名称 + +4. **地区信息聚合** + - 获取任务参与者的地区列表 + +#### 响应示例 + +```json +{ + "total": 5, + "page": 1, + "page_size": 10, + "items": [ + { + "task_id": 1, + "task_name": "2024年度交叉评查任务", + "task_status": "in_progress", + "doc_type": "行政处罚", + "task_type": "CITY", + "task_created_at": "2024-01-01T10:00:00", + "progress": 75, + "total_documents": 20, + "evaluation_region": ["梅州", "云浮"] + }, + { + "task_id": 2, + "task_name": "2024年Q1评查", + "task_status": "completed", + "doc_type": "行政许可", + "task_type": "DISTRICT", + "task_created_at": "2024-02-01T10:00:00", + "progress": 100, + "total_documents": 15, + "evaluation_region": ["揭阳"] + } + ] +} +``` + +#### 返回字段说明 + +| 字段 | 类型 | 说明 | +|------|------|------| +| `task_id` | int | 任务ID | +| `task_name` | string | 任务名称 | +| `task_status` | string | 任务状态(in_progress/completed) | +| `doc_type` | string | 文档类型名称 | +| `task_type` | string | 任务类型(CITY/DISTRICT等) | +| `task_created_at` | datetime | 任务创建时间 | +| `progress` | int | 完成进度百分比 (0-100) | +| `total_documents` | int | 任务包含的文档总数 | +| `evaluation_region` | array | 参与评查的地区列表 | + +--- + +### 8. 获取评查任务进度 + +**GET** `/api/v2/cross_review/tasks/{task_id}/progress` + +根据任务ID获取评查进度详情。**仅任务参与者可访问**。 + +**权限**: `cross_review:progress:view` + +#### 路径参数 + +| 参数 | 类型 | 说明 | +|------|------|------| +| `task_id` | int | 任务ID(必须大于0) | + +#### 请求体 + +无需请求体。 + +#### 业务逻辑 + +1. **权限验证** + - 验证当前用户是否是任务的参与者(在 `user_ids` 中) + +2. **进度计算** + - 获取任务关联的所有文档映射(排除已删除的) + - 统计已完成文档数(`audit_status = 1`) + - 计算完成百分比 + +#### 响应示例 + +```json +{ + "code": 0, + "task_id": 1, + "total_documents": 20, + "completed_documents": 15, + "progress": 75.0 +} +``` + +#### 返回字段说明 + +| 字段 | 类型 | 说明 | +|------|------|------| +| `code` | int | 状态码(0表示成功) | +| `task_id` | int | 任务ID | +| `total_documents` | int | 文档总数 | +| `completed_documents` | int | 已完成文档数 | +| `progress` | float | 完成百分比(保留2位小数) | + +#### 错误码 + +| 状态码 | 错误信息 | 说明 | +|--------|----------|------| +| `403` | 无权访问任务:您不是该任务的参与者 | 用户不是任务参与者 | +| `404` | 任务不存在 | 无效的任务ID | + +--- + +### 9. 获取任务下文档列表 + +**POST** `/api/v2/cross_review/tasks/{task_id}/documents` + +获取指定任务下的文档列表,支持多种筛选条件和排序。**仅任务参与者可访问**。 + +**权限**: `cross_review:task:read` + +#### 路径参数 + +| 参数 | 类型 | 说明 | +|------|------|------| +| `task_id` | int | 任务ID(必须大于0) | + +#### 请求参数 + +| 字段 | 类型 | 必填 | 默认值 | 说明 | +|------|------|------|--------|------| +| `page` | int | 否 | 1 | 页码(从1开始) | +| `page_size` | int | 否 | 10 | 每页数量 | +| `file_type_ids` | array[int] | 否 | null | 文件类型ID列表(不传查所有类型) | +| `date_from` | string | 否 | null | 起始日期 (yyyy-MM-dd) | +| `date_to` | string | 否 | null | 结束日期 (yyyy-MM-dd) | +| `keyword` | string | 否 | null | 搜索关键字(匹配文件名或文书号) | +| `order` | string | 否 | `upload_time_desc` | 排序方式 | + +#### 排序方式 + +| 值 | 说明 | +|----|------| +| `upload_time_desc` | 上传时间降序(默认,最新的在前) | +| `upload_time_asc` | 上传时间升序(最早的在前) | + +#### 请求示例 + +```json +{ + "page": 1, + "page_size": 10, + "file_type_ids": [1, 2], + "date_from": "2024-01-01", + "date_to": "2024-12-31", + "keyword": "处罚", + "order": "upload_time_desc" +} +``` + +#### 业务逻辑 + +1. **权限验证** + - 验证当前用户是否是任务的参与者 + +2. **文档查询** + - 从 `cross_task_document_mapping` 获取任务关联的文档 + - 应用各种筛选条件 + - 关联 `documents` 和 `document_types` 表 + +3. **评查统计** + - 统计每个文档的评查结果 + - 计算通过/警告/失败/人工审核数量 + - 计算最终得分和满分 + - 获取问题摘要(最多5条) + +#### 响应示例 + +```json +{ + "total": 100, + "page": 1, + "page_size": 10, + "items": [ + { + "document_id": 123, + "file_name": "行政处罚决定书_001.pdf", + "status": "completed", + "path": "/documents/2024/01/123.pdf", + "file_code": "穗市监罚字[2024]001号", + "file_type_name": "行政处罚", + "file_type_id": 1, + "file_size": 1024000, + "upload_time": "2024-01-01T10:00:00", + "created_at": "2024-01-01T10:00:00", + "evaluations_status": "completed", + "audit_status": 1, + "created_by_user_id": 1, + "final_score": 85.5, + "full_score": 100, + "score_summary": "85.5/100", + "score_percent": 85.5, + "pass_count": 18, + "warning_count": 2, + "fail_count": 1, + "manual_count": 3, + "issues": [ + { + "severity": "error", + "message": "缺少当事人签名" + }, + { + "severity": "warning", + "message": "日期格式不规范" + } + ] + } + ] +} +``` + +#### 返回字段说明 + +| 字段 | 类型 | 说明 | +|------|------|------| +| `document_id` | int | 文档ID | +| `file_name` | string | 文件名 | +| `status` | string | 文档处理状态 | +| `path` | string | 文件存储路径 | +| `file_code` | string | 文书号 | +| `file_type_name` | string | 文件类型名称 | +| `file_type_id` | int | 文件类型ID | +| `file_size` | int | 文件大小(字节) | +| `upload_time` | datetime | 上传时间 | +| `created_at` | datetime | 创建时间 | +| `evaluations_status` | string | 评查状态 | +| `audit_status` | int | 审核状态 (0:待审核, 1:已完成) | +| `created_by_user_id` | int | 创建用户ID | +| `final_score` | float | 最终得分 | +| `full_score` | float | 满分 | +| `score_summary` | string | 得分摘要(如 "85.5/100") | +| `score_percent` | float | 得分百分比 | +| `pass_count` | int | 通过的评查点数量 | +| `warning_count` | int | 警告的评查点数量(severity=warning或info) | +| `fail_count` | int | 失败的评查点数量(severity=error) | +| `manual_count` | int | 需人工审核的评查点数量(post_action=manual) | +| `issues` | array | 问题列表(最多5条,包含severity和message) | + +#### 错误码 + +| 状态码 | 错误信息 | 说明 | +|--------|----------|------| +| `403` | 无权访问任务:您不是该任务的参与者 | 用户不是任务参与者 | + +--- + +### 10. 确认完成文档评查 + +**POST** `/api/v2/cross_review/tasks/{task_id}/documents/{document_id}/complete` + +确认完成对指定文档的评查,更新任务进度。 + +**权限**: `cross_review:document:complete` + +#### 路径参数 + +| 参数 | 类型 | 说明 | +|------|------|------| +| `task_id` | int | 任务ID(必须大于0) | +| `document_id` | int | 文档ID(必须大于0) | + +#### 请求体 + +无需请求体。 + +#### 业务逻辑 + +1. **任务存在性验证** + - 验证任务存在 + +2. **权限验证** + - 验证当前用户是否是任务的参与者 + +3. **文档归属验证** + - 验证文档确实属于该任务(在 `cross_task_document_mapping` 中) + +4. **状态更新** + - 将 `cross_task_document_mapping.audit_status` 设置为 `1`(已完成) + +#### 响应示例 + +```json +{ + "code": 0, + "success": true, + "message": "文档评查已完成", + "task_id": 1, + "document_id": 123 +} +``` + +#### 错误码 + +| 状态码 | 错误信息 | 说明 | +|--------|----------|------| +| `400` | 任务不存在 | 无效的任务ID | +| `400` | 文档不在任务中 | 文档不属于该任务 | +| `403` | 用户不是任务的参与者 | 权限不足 | + +--- + +## 核心业务规则 + +### 提案投票机制 + +1. **投票通过条件**: 同意票数 >= ⌊参与人数/2⌋ + 1 +2. **投票否决条件**: + - 反对票数 >= ⌊参与人数/2⌋ + 1 + - 或剩余可投票数不足以达到通过条件 + +3. **提案人自动投票**: 创建提案时,系统自动为提案人投一票"同意" + +4. **分数自动更新**: 提案通过后,自动将 `proposed_score` 累加到评查结果的 `final_score` + +### 分数校验规则 + +| 场景 | 限制 | +|------|------| +| 创建0分提案 | 禁止 | +| 当前分数为0时扣分 | 禁止 | +| 当前分数已满分时加分 | 禁止 | +| 分数计算后超过满分 | 自动截断为满分 | +| 分数计算后小于0 | 自动截断为0 | + +### 文档完成自动标记 + +当文档下所有提案都达到终态(approved 或 rejected)时,系统自动将该文档在任务中的状态标记为已完成(`audit_status=1`)。 + +--- + +## 权限列表 + +| 权限 Key | 说明 | +|----------|------| +| `cross_review:proposal:create` | 创建提案 | +| `cross_review:proposal:read` | 查看提案 | +| `cross_review:proposal:delete` | 撤销提案 | +| `cross_review:proposal:vote` | 提案投票 | +| `cross_review:task:read` | 查看任务 | +| `cross_review:progress:view` | 查看进度 | +| `cross_review:document:complete` | 完成文档评查 | + +--- + +## 枚举值说明 + +### VoteType 投票类型 + +| 值 | 说明 | +|----|------| +| `agree` | 同意 | +| `disagree` | 反对 | +| `cancel` | 取消投票 | + +### ProposalStatus 提案状态 + +| 值 | 说明 | +|----|------| +| `pending` | 待处理(投票中) | +| `approved` | 已通过(分数已更新) | +| `rejected` | 已否决 | + +### DocType 文档类型 + +| 值 | 说明 | +|----|------| +| `XZCF` | 行政处罚 | +| `XZXK` | 行政许可 | + +### audit_status 任务内文档审核状态 + +| 值 | 说明 | +|----|------| +| `0` | 待审核/进行中 | +| `1` | 已完成 | + +### task_status 任务状态 + +| 值 | 说明 | +|----|------| +| `in_progress` | 进行中 | +| `completed` | 已完成 | + +--- + +## 数据库表关系 + +``` +cross_examination_tasks (交叉评查任务) +├── id: 任务ID +├── assigner_id: 创建人ID +├── user_ids: 参与者ID数组 +├── task_name: 任务名称 +├── task_status: 任务状态 +├── doc_type: 文档类型 +└── task_type: 任务类型 + +cross_task_document_mapping (任务-文档映射) +├── task_id: 任务ID +├── document_id: 文档ID +├── audit_status: 文档审核状态 +└── deleted_at: 软删除标记 + +cross_scoring_proposals (评分提案) +├── id: 提案ID +├── document_id: 文档ID +├── evaluation_point_id: 评查点ID +├── evaluation_result_id: 评查结果ID +├── proposed_score: 提议分数 +├── reason: 理由 +├── proposer_id: 提案人ID +├── status: 提案状态 +└── deleted_at: 软删除标记 + +cross_opinion_votes (投票记录) +├── proposal_id: 提案ID +├── voter_id: 投票人ID +├── vote_type: 投票类型 +└── deleted_at: 软删除标记 +``` + +--- + +## 通用错误响应格式 + +```json +{ + "detail": "错误信息描述" +} +``` + +**HTTP 状态码**: +- `400 Bad Request`: 请求参数错误/业务规则校验失败 +- `403 Forbidden`: 权限不足 +- `404 Not Found`: 资源不存在 +- `500 Internal Server Error`: 服务器内部错误 + +--- + +## 前端对接注意事项 + +1. **所有接口都需要认证**: 必须在 Header 中携带有效的 JWT Token + +2. **evaluation_result_id 必填**: 创建提案时必须提供评查结果ID + +3. **任务权限检查**: 大部分任务相关接口会校验当前用户是否为任务参与者 + +4. **分页参数**: 分页从第1页开始,不是第0页 + +5. **日期格式**: 使用 `yyyy-MM-dd` 格式(如 `2024-01-01`) + +6. **投票后刷新**: 投票后应刷新提案列表,因为提案状态可能已自动更新 + +7. **proposed_score 含义**: + - 正数表示加分 + - 负数表示扣分 + - 0 不允许 + +8. **can_vote 字段**: 前端应根据此字段决定是否显示投票按钮 diff --git a/auth_doc/评查审核接口对接文档.md b/auth_doc/评查审核接口对接文档.md new file mode 100644 index 0000000..d9f1400 --- /dev/null +++ b/auth_doc/评查审核接口对接文档.md @@ -0,0 +1,741 @@ +# 评查审核接口对接文档 + +> 版本:v1.0 +> 更新时间:2024-12-10 +> 模块:评查审核(Evaluation Audit) + +--- + +## 一、接口总览 + +| 序号 | 接口名称 | 方法 | 路径 | 功能说明 | +| ---- | ------------ | --------- | ----------------------------------------------- | ---------------------------- | +| 1 | 更新评查结果 | `PATCH` | `/admin/v2/evaluation/results/{result_id}` | 修改评查结果的通过状态和说明 | +| 2 | 创建审核状态 | `POST` | `/admin/v2/evaluation/audit-status` | 记录人工审核操作 | +| 3 | 更新审核状态 | `PATCH` | `/admin/v2/evaluation/audit-status/{id}` | 更新已有的审核状态记录 | +| 4 | 确认文档审核 | `PATCH` | `/admin/v2/evaluation/documents/{id}/confirm` | 确认文档整体审核完成 | + +--- + +## 二、通用说明 + +### 2.1 请求头 + +所有接口都需要携带 JWT Token: + +```http +Authorization: Bearer +Content-Type: application/json +``` + +### 2.2 基础路径 + +``` +开发环境: http://localhost:8000 +生产环境: https://your-domain.com +``` + +### 2.3 通用响应格式 + +**成功响应**: + +```json +{ + "success": true, + "message": "操作成功", + "data": { ... } +} +``` + +**错误响应**: + +```json +{ + "detail": "错误信息" +} +``` + +### 2.4 HTTP 状态码 + +| 状态码 | 说明 | +| ------ | -------------- | +| 200 | 请求成功 | +| 400 | 请求参数错误 | +| 403 | 无权限访问 | +| 404 | 资源不存在 | +| 500 | 服务器内部错误 | + +--- + +## 三、接口详细说明 + +--- + +### 3.1 更新评查结果 + +**功能说明**:人工审核时,修改某个评查点的评查结果(通过/不通过)和说明信息。 + +#### 请求信息 + +| 项目 | 说明 | +| -------- | --------------------------------------------- | +| 请求路径 | `/admin/v2/evaluations/results/{result_id}` | +| 请求方法 | `PATCH` | +| 权限要求 | 用户必须对该文档有访问权限 | + +#### 路径参数 + +| 参数名 | 类型 | 必填 | 说明 | +| --------- | ------- | ---- | ----------------------------------------- | +| result_id | integer | 是 | 评查结果ID(evaluation_results 表的主键) | + +#### 请求体参数 + +| 参数名 | 类型 | 必填 | 说明 | +| ----------- | ------ | ---- | ----------------------------------------- | +| result | string | 否 | 评查结果,可选值:`"pass"` / `"fail"` | +| message | string | 否 | 评查结果说明(人工审核备注) | +| final_score | float | 否 | 最终分数(人工修正后的分数) | + +#### 请求示例 + +```http +PATCH /admin/v2/evaluation/results/123 +Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9... +Content-Type: application/json + +{ + "result": "pass", + "message": "经人工审核,该评查点符合要求", + "final_score": 100.0 +} +``` + +#### 响应示例 + +**成功响应** (200): + +```json +{ + "success": true, + "message": "评查结果更新成功", + "data": { + "result_id": 123, + "updated_fields": ["evaluated_results", "final_score"] + } +} +``` + +**错误响应** (404): + +```json +{ + "detail": "评查结果 123 不存在" +} +``` + +**错误响应** (403): + +```json +{ + "detail": "无权修改此评查结果" +} +``` + +#### 业务逻辑 + +``` +1. 接收请求,验证 JWT Token +2. 根据 result_id 查询 evaluation_results 表 +3. 获取该评查结果关联的 document_id +4. 验证当前用户是否有权限访问该文档: + - 是文档上传者 + - 或是交叉评查任务的参与者 +5. 更新 evaluation_results 表: + - evaluated_results.result = 请求的 result + - evaluated_results.message = 请求的 message + - final_score = 请求的 final_score +6. 返回更新结果 +``` + +#### 数据库变更 + +更新 `evaluation_results` 表: + +- `evaluated_results` (JSONB): 更新其中的 `result` 和 `message` 字段 +- `final_score` (float): 更新最终分数 + +--- + +### 3.2 创建审核状态 + +**功能说明**:记录用户对某个评查点的人工审核操作,用于追踪审核历史。 + +#### 请求信息 + +| 项目 | 说明 | +| -------- | -------------------------------------- | +| 请求路径 | `/admin/v2/evaluations/audit-status` | +| 请求方法 | `POST` | +| 权限要求 | 用户必须对该文档有访问权限 | + +#### 请求体参数 + +| 参数名 | 类型 | 必填 | 说明 | +| -------------------- | ------- | ---- | ------------------------------------ | +| document_id | integer | 是 | 文档ID | +| evaluation_point_id | integer | 是 | 评查点ID | +| evaluation_result_id | integer | 是 | 评查结果ID | +| edit_audit_status | integer | 是 | 审核状态:`0`=未审核,`1`=已审核 | +| message | string | 否 | 操作记录文本(最大255字符) | + +#### 请求示例 + +```http +POST /admin/v2/evaluation/audit-status +Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9... +Content-Type: application/json + +{ + "document_id": 456, + "evaluation_point_id": 789, + "evaluation_result_id": 123, + "edit_audit_status": 1, + "message": "已完成人工审核,确认通过" +} +``` + +#### 响应示例 + +**成功响应** (200): + +```json +{ + "id": 1, + "user_id": 10, + "document_id": 456, + "evaluation_point_id": 789, + "evaluation_result_id": 123, + "edit_audit_status": 1, + "message": "已完成人工审核,确认通过", + "created_at": "2024-12-10T10:30:00+08:00", + "updated_at": "2024-12-10T10:30:00+08:00" +} +``` + +**错误响应** (403): + +```json +{ + "detail": "无权操作此文档的审核状态" +} +``` + +#### 业务逻辑 + +``` +1. 接收请求,验证 JWT Token +2. 验证当前用户是否有权限访问 document_id 对应的文档 +3. 在 audit_status 表中插入新记录: + - user_id = 当前登录用户ID(自动填充) + - document_id = 请求的 document_id + - evaluation_point_id = 请求的 evaluation_point_id + - evaluation_result_id = 请求的 evaluation_result_id + - edit_audit_status = 请求的 edit_audit_status + - message = 请求的 message +4. 返回创建的记录 +``` + +#### 数据库变更 + +在 `audit_status` 表中插入新记录 + +#### 前端使用场景 + +当用户点击"通过"或"不通过"按钮时: + +1. 先调用 **3.1 更新评查结果** 更新结果 +2. 再调用 **3.2 创建审核状态** 记录操作 + +--- + +### 3.3 更新审核状态 + +**功能说明**:更新已有的审核状态记录,用于"重新审核"等场景。 + +#### 请求信息 + +| 项目 | 说明 | +| -------- | -------------------------------------------------------- | +| 请求路径 | `/admin/v2/evaluations/audit-status/{audit_status_id}` | +| 请求方法 | `PATCH` | +| 权限要求 | 用户必须是该记录的创建者,或对关联文档有访问权限 | + +#### 路径参数 + +| 参数名 | 类型 | 必填 | 说明 | +| --------------- | ------- | ---- | ----------------------------------- | +| audit_status_id | integer | 是 | 审核状态ID(audit_status 表的主键) | + +#### 请求体参数 + +| 参数名 | 类型 | 必填 | 说明 | +| ----------------- | ------- | ---- | ------------------------------------ | +| edit_audit_status | integer | 否 | 审核状态:`0`=未审核,`1`=已审核 | +| message | string | 否 | 操作记录文本(最大255字符) | + +#### 请求示例 + +```http +PATCH /admin/v2/evaluation/audit-status/1 +Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9... +Content-Type: application/json + +{ + "edit_audit_status": 1, + "message": "重新审核后确认通过" +} +``` + +#### 响应示例 + +**成功响应** (200): + +```json +{ + "success": true, + "message": "审核状态更新成功", + "data": { + "audit_status_id": 1, + "updated_fields": ["edit_audit_status", "message"] + } +} +``` + +**错误响应** (404): + +```json +{ + "detail": "审核状态记录 1 不存在" +} +``` + +#### 业务逻辑 + +``` +1. 接收请求,验证 JWT Token +2. 根据 audit_status_id 查询 audit_status 表 +3. 验证权限: + - 如果是自己创建的记录,允许更新 + - 如果不是自己的记录,检查是否有关联文档的访问权限 +4. 更新 audit_status 表对应记录 +5. 返回更新结果 +``` + +#### 数据库变更 + +更新 `audit_status` 表: + +- `edit_audit_status`: 审核状态 +- `message`: 操作记录 +- `updated_at`: 自动更新(数据库触发器) + +--- + +### 3.4 确认文档审核完成 + +**功能说明**:确认整个文档的审核完成,更新文档的审核状态字段。 + +#### 请求信息 + +| 项目 | 说明 | +| -------- | --------------------------------------------------------- | +| 请求路径 | `/admin/v2/evaluations/documents/{document_id}/confirm` | +| 请求方法 | `PATCH` | +| 权限要求 | 用户必须对该文档有访问权限 | + +#### 路径参数 + +| 参数名 | 类型 | 必填 | 说明 | +| ----------- | ------- | ---- | ------ | +| document_id | integer | 是 | 文档ID | + +#### 请求体参数 + +| 参数名 | 类型 | 必填 | 默认值 | 说明 | +| ------------ | ------- | ---- | ------ | ------------------------ | +| audit_status | integer | 否 | 1 | 审核状态:`1`=审核完成 | + +#### 请求示例 + +```http +PATCH /admin/v2/evaluation/documents/456/confirm +Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9... +Content-Type: application/json + +{ + "audit_status": 1 +} +``` + +或者使用默认值(不传请求体): + +```http +PATCH /admin/v2/evaluation/documents/456/confirm +Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9... +Content-Type: application/json + +{} +``` + +#### 响应示例 + +**成功响应** (200): + +```json +{ + "success": true, + "message": "文档审核确认成功", + "data": { + "document_id": 456, + "audit_status": 1 + } +} +``` + +**错误响应** (403): + +```json +{ + "detail": "无权确认此文档的审核状态" +} +``` + +#### 业务逻辑 + +``` +1. 接收请求,验证 JWT Token +2. 验证当前用户是否有权限访问 document_id 对应的文档 +3. 更新 documents 表的 audit_status 字段 +4. 返回更新结果 +``` + +#### 数据库变更 + +更新 `documents` 表: + +- `audit_status`: 设置为 1(审核完成) + +--- + +## 四、完整业务流程 + +### 4.1 人工审核单个评查点 + +```mermaid +sequenceDiagram + participant 前端 + participant 后端 + participant 数据库 + + 前端->>后端: PATCH /results/{id} (修改评查结果) + 后端->>数据库: 验证权限 + 后端->>数据库: 更新 evaluation_results + 后端-->>前端: 返回成功 + + 前端->>后端: POST /audit-status (记录审核操作) + 后端->>数据库: 插入 audit_status + 后端-->>前端: 返回创建的记录 +``` + +### 4.2 确认文档整体审核完成 + +```mermaid +sequenceDiagram + participant 前端 + participant 后端 + participant 数据库 + + Note over 前端: 用户完成所有评查点审核后 + 前端->>后端: PATCH /documents/{id}/confirm + 后端->>数据库: 验证权限 + 后端->>数据库: 更新 documents.audit_status = 1 + 后端-->>前端: 返回成功 +``` + +### 4.3 前端调用示例(TypeScript/Axios) + +```typescript +import axios from 'axios'; + +const API_BASE = '/admin/v2/evaluation'; + +// 获取 JWT Token +const getToken = () => localStorage.getItem('token'); + +// 创建 axios 实例 +const api = axios.create({ + headers: { + 'Content-Type': 'application/json', + }, +}); + +// 添加请求拦截器 +api.interceptors.request.use((config) => { + const token = getToken(); + if (token) { + config.headers.Authorization = `Bearer ${token}`; + } + return config; +}); + +/** + * 更新评查结果 + * @param resultId 评查结果ID + * @param data 更新数据 + */ +export async function updateEvaluationResult( + resultId: number, + data: { + result?: 'pass' | 'fail'; + message?: string; + final_score?: number; + } +) { + const response = await api.patch(`${API_BASE}/results/${resultId}`, data); + return response.data; +} + +/** + * 创建审核状态记录 + * @param data 审核状态数据 + */ +export async function createAuditStatus(data: { + document_id: number; + evaluation_point_id: number; + evaluation_result_id: number; + edit_audit_status: 0 | 1; + message?: string; +}) { + const response = await api.post(`${API_BASE}/audit-status`, data); + return response.data; +} + +/** + * 更新审核状态记录 + * @param auditStatusId 审核状态ID + * @param data 更新数据 + */ +export async function updateAuditStatus( + auditStatusId: number, + data: { + edit_audit_status?: 0 | 1; + message?: string; + } +) { + const response = await api.patch(`${API_BASE}/audit-status/${auditStatusId}`, data); + return response.data; +} + +/** + * 确认文档审核完成 + * @param documentId 文档ID + */ +export async function confirmDocumentReview(documentId: number) { + const response = await api.patch(`${API_BASE}/documents/${documentId}/confirm`, { + audit_status: 1, + }); + return response.data; +} + +// ============================================ +// 业务场景示例 +// ============================================ + +/** + * 场景1:用户点击"通过"按钮 + */ +async function handlePassClick( + documentId: number, + evaluationPointId: number, + evaluationResultId: number +) { + try { + // 1. 更新评查结果为"通过" + await updateEvaluationResult(evaluationResultId, { + result: 'pass', + message: '人工审核通过', + final_score: 100, + }); + + // 2. 记录审核操作 + await createAuditStatus({ + document_id: documentId, + evaluation_point_id: evaluationPointId, + evaluation_result_id: evaluationResultId, + edit_audit_status: 1, + message: '用户确认通过', + }); + + console.log('审核成功'); + } catch (error) { + console.error('审核失败:', error); + } +} + +/** + * 场景2:用户点击"不通过"按钮 + */ +async function handleFailClick( + documentId: number, + evaluationPointId: number, + evaluationResultId: number, + reason: string +) { + try { + // 1. 更新评查结果为"不通过" + await updateEvaluationResult(evaluationResultId, { + result: 'fail', + message: reason, + final_score: 0, + }); + + // 2. 记录审核操作 + await createAuditStatus({ + document_id: documentId, + evaluation_point_id: evaluationPointId, + evaluation_result_id: evaluationResultId, + edit_audit_status: 1, + message: `用户确认不通过: ${reason}`, + }); + + console.log('审核成功'); + } catch (error) { + console.error('审核失败:', error); + } +} + +/** + * 场景3:用户完成所有评查点审核,点击"确认完成" + */ +async function handleConfirmAllClick(documentId: number) { + try { + await confirmDocumentReview(documentId); + console.log('文档审核确认成功'); + } catch (error) { + console.error('确认失败:', error); + } +} +``` + +--- + +## 五、数据库表结构参考 + +### 5.1 evaluation_results 表 + +| 字段名 | 类型 | 说明 | +| --------------------------- | --------------- | -------------------------------------------- | +| id | integer | 主键 | +| document_id | integer | 文档ID | +| evaluation_point_id | integer | 评查点ID | +| status | varchar(20) | 状态 | +| extracted_results | jsonb | 抽取结果 | +| rules_results | jsonb | 规则判断结果 | +| **evaluated_results** | **jsonb** | **评查结果(包含 result 和 message)** | +| **final_score** | **float** | **最终分数** | +| machine_score | float | 机器评查分数 | +| created_at | timestamp | 创建时间 | +| updated_at | timestamp | 更新时间 | + +**evaluated_results 字段结构**: + +```json +{ + "result": "pass", // 评查结果:pass/fail + "message": "说明信息" // 评查结果说明 +} +``` + +### 5.2 audit_status 表 + +| 字段名 | 类型 | 说明 | +| --------------------------- | ---------------------- | ---------------------------------- | +| id | integer | 主键 | +| user_id | integer | 操作用户ID | +| document_id | integer | 文档ID | +| evaluation_point_id | integer | 评查点ID | +| evaluation_result_id | integer | 评查结果ID | +| **edit_audit_status** | **integer** | **审核状态(0=未审核,1=已审核)** | +| **message** | **varchar(255)** | **操作记录文本** | +| created_at | timestamp | 创建时间 | +| updated_at | timestamp | 更新时间 | + +### 5.3 documents 表(相关字段) + +| 字段名 | 类型 | 说明 | +| ---------------------- | ----------------- | ---------------------------------- | +| id | integer | 主键 | +| **audit_status** | **integer** | **审核状态(0=待审核,1=已完成)** | +| ... | ... | 其他字段省略 | + +--- + +## 六、错误处理 + +### 6.1 常见错误码 + +| HTTP 状态码 | detail 示例 | 原因 | 解决方案 | +| ----------- | ----------------------- | ------------------ | ---------------------------------------- | +| 403 | "无权修改此评查结果" | 用户无权访问该文档 | 检查用户是否是文档上传者或交叉评查参与者 | +| 404 | "评查结果 123 不存在" | 评查结果ID不存在 | 检查传入的 result_id 是否正确 | +| 404 | "审核状态记录 1 不存在" | 审核状态ID不存在 | 检查传入的 audit_status_id 是否正确 | +| 422 | "field required" | 缺少必填字段 | 检查请求体是否包含所有必填字段 | +| 500 | "更新评查结果失败: ..." | 服务器内部错误 | 查看服务器日志排查问题 | + +### 6.2 前端错误处理示例 + +```typescript +try { + await updateEvaluationResult(resultId, data); +} catch (error) { + if (axios.isAxiosError(error)) { + const status = error.response?.status; + const detail = error.response?.data?.detail; + + switch (status) { + case 403: + alert('您没有权限执行此操作'); + break; + case 404: + alert('记录不存在,请刷新页面后重试'); + break; + case 422: + alert('请求参数错误,请检查输入'); + break; + default: + alert(`操作失败: ${detail || '未知错误'}`); + } + } +} +``` + +--- + +## 七、注意事项 + +1. **权限验证**:所有接口都会验证用户对文档的访问权限,前端无需额外处理权限逻辑 +2. **字段更新**:PATCH 接口只更新传入的字段,未传入的字段保持不变 +3. **操作顺序**:建议先调用"更新评查结果",再调用"创建审核状态" +4. **审核状态值**: + - `edit_audit_status = 0`:未审核(按钮显示"通过/不通过") + - `edit_audit_status = 1`:已审核(按钮显示"重新审核") +5. **文档 audit_status**: + - `audit_status = 0`:待人工确认 + - `audit_status = 1`:审核完成 + +--- + +## 八、联系方式 + +如有问题,请联系后端开发人员。 diff --git a/auth_doc/通用权限前端对接文档(1).md b/auth_doc/通用权限前端对接文档(1).md new file mode 100644 index 0000000..b3d4018 --- /dev/null +++ b/auth_doc/通用权限前端对接文档(1).md @@ -0,0 +1,742 @@ +# 通用权限前端对接文档 + +## 概述 + +系统中存在**通用权限**,即同一个权限被多个页面共享使用。例如「查看审计状态」权限同时被「文档评查结果详情」和「交叉评查-评查结果」两个页面使用。 + +本文档详细说明前端如何查询、展示和管理这些通用权限。 + +--- + +## ⚠️ 重要:必须修改的查询方式 + +### 问题 + +当前前端查询某个路由下的权限可能使用: + +```javascript +// ❌ 错误方式:只能查到独立权限,查不到通用权限 +const permissions = await fetch(`/api/postgrest/proxy/permissions?route_id=eq.${routeId}`); +``` + +这种查询方式**无法获取通用权限**,因为通用权限的 `route_id` 为 `NULL`。 + +### 解决方案 + +**必须修改为以下查询方式**: + +```javascript +// ✅ 正确方式:同时查询独立权限 + 通用权限 +const permissions = await fetch( + `/api/postgrest/proxy/permissions?or=(route_id.eq.${routeId},related_routes.cs.{${routeId}})` +); +``` + +### 查询参数说明 + +| 参数 | 说明 | +|------|------| +| `route_id.eq.${routeId}` | 查询独立权限(route_id = 指定值) | +| `related_routes.cs.{${routeId}}` | 查询通用权限(related_routes 数组包含指定值) | +| `or=(...)` | 两个条件取并集 | +| `cs` | PostgREST 的 contains 操作符,用于数组包含查询 | + +### 修改前后对比 + +以交叉评查页面 (route_id=37) 为例: + +| 查询方式 | 返回权限数量 | 说明 | +|---------|-------------|------| +| `?route_id=eq.37` | 6个 | ❌ 只有独立权限 | +| `?or=(route_id.eq.37,related_routes.cs.{37})` | 11个 | ✅ 独立权限 + 通用权限 | + +### 交叉评查页面应显示的完整权限列表 + +| 类型 | 权限名称 | permission_key | +|------|---------|----------------| +| **通用** | 查看审计状态 | evaluation:audit_status:view | +| **通用** | 更新审计状态 | evaluation:audit_status:update | +| **通用** | 创建审核状态 | evaluation:audit_status:create | +| **通用** | 更新评查结果 | evaluation:result:update | +| **通用** | 确认文档审核完成 | evaluation:document:confirm | +| 独立 | 查看评分提案 | cross_review:proposal:read | +| 独立 | 创建评分提案 | cross_review:proposal:create | +| 独立 | 对提案投票 | cross_review:proposal:vote | +| 独立 | 删除评分提案 | cross_review:proposal:delete | +| 独立 | 标记文档完成 | cross_review:document:complete | +| 独立 | 查看任务进度 | cross_review:progress:view | + +--- + +## 数据库结构 + +### permissions 表关键字段 + +| 字段 | 类型 | 说明 | +|------|------|------| +| `id` | INTEGER | 权限ID(主键) | +| `permission_key` | VARCHAR(100) | 权限唯一标识,如 `evaluation:audit_status:view` | +| `display_name` | VARCHAR(200) | 权限显示名称 | +| `route_id` | INTEGER | 关联的路由ID(独立权限使用) | +| `related_routes` | INTEGER[] | **关联的多个路由ID(通用权限使用)** | + +### 权限类型区分 + +| 类型 | route_id | related_routes | 说明 | +|------|----------|----------------|------| +| **独立权限** | 有值(如58) | NULL | 只属于单个页面 | +| **通用权限** | NULL | 有值(如{58,37}) | 被多个页面共享 | + +--- + +## 当前通用权限数据 + +```sql +SELECT id, permission_key, route_id, related_routes, display_name +FROM permissions +WHERE related_routes IS NOT NULL; +``` + +| id | permission_key | route_id | related_routes | display_name | +|----|----------------|----------|----------------|--------------| +| 88 | evaluation:audit_status:view | NULL | {58, 37} | 查看审计状态 | +| 89 | evaluation:audit_status:update | NULL | {58, 37} | 更新审计状态 | +| 136 | evaluation:result:update | NULL | {58, 37} | 更新评查结果 | +| 137 | evaluation:audit_status:create | NULL | {58, 37} | 创建审核状态 | +| 138 | evaluation:document:confirm | NULL | {58, 37} | 确认文档审核完成 | + +### 关联的路由 + +| route_id | route_name | route_title | route_path | +|----------|------------|-------------|------------| +| 58 | Reviews | 文档评查结果详情 | /reviews | +| 37 | CrossCheckingResult | 评查结果 | /cross-checking/result | + +--- + +## 查询某个页面的所有权限 + +### SQL 查询语句 + +```sql +-- 查询 route_id=58 (文档评查结果详情) 的所有权限 +SELECT + id, + permission_key, + display_name, + route_id, + related_routes, + CASE + WHEN related_routes IS NOT NULL THEN true + ELSE false + END AS is_shared +FROM permissions +WHERE route_id = 58 OR 58 = ANY(related_routes) +ORDER BY is_shared, permission_key; +``` + +### PostgREST 查询方式 + +```http +GET /api/postgrest/proxy/permissions?or=(route_id.eq.58,related_routes.cs.{58})&select=id,permission_key,display_name,route_id,related_routes +``` + +**参数说明**: +- `route_id.eq.58` - 独立权限(route_id = 58) +- `related_routes.cs.{58}` - 通用权限(related_routes 包含 58) +- `cs` = contains,PostgreSQL 数组包含操作符 + +### 返回示例 + +```json +[ + // 独立权限 + { + "id": 82, + "permission_key": "evaluation_point:result:view", + "display_name": "查看评查结果", + "route_id": 58, + "related_routes": null + }, + { + "id": 83, + "permission_key": "evaluation_point:result:update", + "display_name": "更新评查结果", + "route_id": 58, + "related_routes": null + }, + { + "id": 48, + "permission_key": "review_point:detail:read", + "display_name": "查看评查详情", + "route_id": 58, + "related_routes": null + }, + // 通用权限 + { + "id": 88, + "permission_key": "evaluation:audit_status:view", + "display_name": "查看审计状态", + "route_id": null, + "related_routes": [58, 37] + }, + { + "id": 89, + "permission_key": "evaluation:audit_status:update", + "display_name": "更新审计状态", + "route_id": null, + "related_routes": [58, 37] + }, + { + "id": 136, + "permission_key": "evaluation:result:update", + "display_name": "更新评查结果", + "route_id": null, + "related_routes": [58, 37] + }, + { + "id": 137, + "permission_key": "evaluation:audit_status:create", + "display_name": "创建审核状态", + "route_id": null, + "related_routes": [58, 37] + }, + { + "id": 138, + "permission_key": "evaluation:document:confirm", + "display_name": "确认文档审核完成", + "route_id": null, + "related_routes": [58, 37] + } +] +``` + +--- + +## 前端展示逻辑 + +### 页面权限树结构 + +``` +文件管理 (/documents) +├── 文档列表 (/documents/list) +├── 文档评查结果详情 (/reviews) ← route_id=58 +│ ├── [独立] GET 查看评查结果 +│ ├── [独立] PATCH 更新评查结果 +│ ├── [独立] GET 查看评查详情 +│ ├── [通用] GET 查看审计状态 ← is_shared=true +│ ├── [通用] PATCH 更新审计状态 ← is_shared=true +│ ├── [通用] POST 创建审核状态 ← is_shared=true +│ ├── [通用] PATCH 更新评查结果 ← is_shared=true +│ └── [通用] PATCH 确认文档审核完成 ← is_shared=true +│ +交叉评查 (/cross-checking) +├── 上传评查文档 (/cross-checking/upload) +├── 评查结果 (/cross-checking/result) ← route_id=37 +│ ├── [独立] POST 查看评分提案 +│ ├── [独立] POST 创建评分提案 +│ ├── [独立] POST 对提案投票 +│ ├── [独立] DELETE 删除评分提案 +│ ├── [独立] POST 标记文档完成 +│ ├── [独立] GET 查看任务进度 +│ ├── [通用] GET 查看审计状态 ← is_shared=true (同上) +│ ├── [通用] PATCH 更新审计状态 ← is_shared=true (同上) +│ ├── [通用] POST 创建审核状态 ← is_shared=true (同上) +│ ├── [通用] PATCH 更新评查结果 ← is_shared=true (同上) +│ └── [通用] PATCH 确认文档审核完成 ← is_shared=true (同上) +``` + +### 识别通用权限 + +```javascript +// 判断是否为通用权限 +function isSharedPermission(permission) { + return permission.related_routes !== null && + Array.isArray(permission.related_routes) && + permission.related_routes.length > 1; +} + +// 获取通用权限关联的所有路由ID +function getRelatedRouteIds(permission) { + if (isSharedPermission(permission)) { + return permission.related_routes; + } + return permission.route_id ? [permission.route_id] : []; +} +``` + +### UI 展示建议 + +```jsx +// 权限项组件 +function PermissionItem({ permission, checked, onChange }) { + const isShared = isSharedPermission(permission); + + return ( +
+ + + {isShared && 通用} + {permission.display_name} + + {isShared && ( + + + + )} +
+ ); +} +``` + +--- + +## 同步勾选逻辑 + +### 核心逻辑 + +当用户勾选/取消一个**通用权限**时,需要同步更新所有关联路由下该权限的勾选状态。 + +### 实现代码 + +```javascript +// 权限状态管理 +class PermissionManager { + constructor() { + // 存储所有权限数据 + this.permissions = []; + // 存储已勾选的权限ID集合 + this.checkedPermissionIds = new Set(); + // 通用权限ID列表(用于快速查找) + this.sharedPermissionIds = new Set(); + } + + // 加载权限数据 + async loadPermissions() { + // 查询所有权限 + const response = await fetch('/api/postgrest/proxy/permissions?select=*'); + this.permissions = await response.json(); + + // 识别通用权限 + this.sharedPermissionIds = new Set( + this.permissions + .filter(p => p.related_routes !== null) + .map(p => p.id) + ); + } + + // 获取某个路由下的所有权限 + getPermissionsByRouteId(routeId) { + return this.permissions.filter(p => + p.route_id === routeId || + (p.related_routes && p.related_routes.includes(routeId)) + ); + } + + // 勾选权限 + checkPermission(permissionId) { + this.checkedPermissionIds.add(permissionId); + // 通用权限会自动在所有关联路由下显示为勾选状态 + // 因为我们是用 permission_id 来管理勾选状态,而不是 route_id + permission_id + } + + // 取消勾选权限 + uncheckPermission(permissionId) { + this.checkedPermissionIds.delete(permissionId); + } + + // 判断权限是否被勾选 + isPermissionChecked(permissionId) { + return this.checkedPermissionIds.has(permissionId); + } + + // 判断是否为通用权限 + isSharedPermission(permissionId) { + return this.sharedPermissionIds.has(permissionId); + } + + // 获取通用权限关联的路由名称 + getRelatedRouteNames(permission) { + if (!permission.related_routes) return []; + // 需要有路由名称映射 + return permission.related_routes.map(routeId => { + // 从 sys_routes 表获取路由名称 + return this.routeMap[routeId]?.route_title || `路由${routeId}`; + }); + } +} +``` + +### React/Vue 组件示例 + +```jsx +// React 示例 +function PermissionTree({ routeId, permissionManager }) { + const [checkedIds, setCheckedIds] = useState(new Set()); + + // 获取当前路由下的所有权限 + const permissions = permissionManager.getPermissionsByRouteId(routeId); + + // 处理勾选变化 + const handleCheckChange = (permissionId, checked) => { + const newCheckedIds = new Set(checkedIds); + + if (checked) { + newCheckedIds.add(permissionId); + } else { + newCheckedIds.delete(permissionId); + } + + setCheckedIds(newCheckedIds); + + // 如果是通用权限,通知其他关联路由的组件更新UI + if (permissionManager.isSharedPermission(permissionId)) { + // 触发全局状态更新或事件 + eventBus.emit('sharedPermissionChanged', { permissionId, checked }); + } + }; + + // 监听通用权限变化事件 + useEffect(() => { + const handleSharedChange = ({ permissionId, checked }) => { + // 检查这个通用权限是否属于当前路由 + const permission = permissions.find(p => p.id === permissionId); + if (permission) { + // 更新本地勾选状态 + const newCheckedIds = new Set(checkedIds); + if (checked) { + newCheckedIds.add(permissionId); + } else { + newCheckedIds.delete(permissionId); + } + setCheckedIds(newCheckedIds); + } + }; + + eventBus.on('sharedPermissionChanged', handleSharedChange); + return () => eventBus.off('sharedPermissionChanged', handleSharedChange); + }, [permissions, checkedIds]); + + return ( +
+ {permissions.map(permission => ( + handleCheckChange(permission.id, checked)} + /> + ))} +
+ ); +} +``` + +--- + +## 保存角色权限 + +### 请求格式 + +```http +POST /api/v3/rbac/role-permissions +Content-Type: application/json + +{ + "role_id": 2, + "permissions": [ + {"permission_id": 82, "grant_type": "GRANT", "data_scope": "ALL"}, + {"permission_id": 83, "grant_type": "GRANT", "data_scope": "DEPT"}, + {"permission_id": 88, "grant_type": "GRANT", "data_scope": "ALL"}, // 通用权限 + {"permission_id": 89, "grant_type": "GRANT", "data_scope": "ALL"}, // 通用权限 + // ... + ], + "replace": true +} +``` + +**注意**: +- 通用权限只需要提交一次(使用 permission_id) +- 不需要为每个关联路由分别提交 +- 后端权限判断基于 `permission_key`,与 route_id 无关 + +--- + +## 完整工作流程 + +### 1. 加载权限配置页面 + +```javascript +async function loadPermissionConfigPage(roleId) { + // 1. 获取所有路由树 + const routes = await fetch('/api/postgrest/proxy/sys_routes?deleted_at=is.null&order=sort_order'); + + // 2. 获取所有权限 + const permissions = await fetch('/api/postgrest/proxy/permissions?select=*'); + + // 3. 获取角色已有的权限 + const rolePermissions = await fetch(`/api/v3/rbac/role-permissions?role_id=${roleId}`); + + // 4. 构建权限树(每个路由下挂载对应的权限) + const permissionTree = buildPermissionTree(routes, permissions); + + // 5. 标记已勾选的权限 + markCheckedPermissions(permissionTree, rolePermissions.data.permissions); + + return permissionTree; +} + +function buildPermissionTree(routes, permissions) { + return routes.map(route => ({ + ...route, + permissions: permissions.filter(p => + p.route_id === route.id || + (p.related_routes && p.related_routes.includes(route.id)) + ).map(p => ({ + ...p, + isShared: p.related_routes !== null + })) + })); +} +``` + +### 2. 用户交互 + +```javascript +// 勾选权限 +function onPermissionCheck(permissionId, checked) { + // 更新本地状态 + updateLocalState(permissionId, checked); + + // 如果是通用权限,同步更新所有关联路由的UI + const permission = findPermission(permissionId); + if (permission.isShared) { + syncSharedPermissionUI(permission, checked); + } +} + +// 同步通用权限UI +function syncSharedPermissionUI(permission, checked) { + permission.related_routes.forEach(routeId => { + // 更新对应路由节点下该权限的勾选状态 + updateRoutePermissionUI(routeId, permission.id, checked); + }); +} +``` + +### 3. 保存配置 + +```javascript +async function saveRolePermissions(roleId) { + // 收集所有勾选的权限ID(通用权限只收集一次) + const checkedPermissionIds = collectCheckedPermissionIds(); + + // 构建请求数据 + const requestData = { + role_id: roleId, + permissions: checkedPermissionIds.map(id => ({ + permission_id: id, + grant_type: 'GRANT', + data_scope: getDataScope(id) // 根据业务需求设置 + })), + replace: true + }; + + // 提交到后端 + await fetch('/api/v3/rbac/role-permissions', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(requestData) + }); +} +``` + +--- + +## 样式建议 + +```css +/* 通用权限样式 */ +.permission-item.shared { + background-color: #f0f7ff; + border-left: 3px solid #1890ff; +} + +.permission-item.shared .permission-name { + color: #1890ff; +} + +/* 通用标签 */ +.shared-tag { + display: inline-block; + padding: 0 6px; + font-size: 12px; + background-color: #e6f7ff; + border: 1px solid #91d5ff; + border-radius: 2px; + color: #1890ff; + margin-right: 8px; +} + +/* 同步勾选提示 */ +.sync-indicator { + font-size: 12px; + color: #999; + margin-left: 8px; +} +``` + +--- + +## API 接口汇总 + +| 接口 | 方法 | 说明 | +|------|------|------| +| `/api/postgrest/proxy/permissions` | GET | 查询权限列表 | +| `/api/postgrest/proxy/sys_routes` | GET | 查询路由列表 | +| `/api/v3/rbac/role-permissions` | GET | 查询角色已有权限 | +| `/api/v3/rbac/role-permissions` | POST | 保存角色权限配置 | + +### 查询示例 + +```bash +# 查询所有权限 +curl '/api/postgrest/proxy/permissions?select=id,permission_key,display_name,route_id,related_routes' + +# 查询 route_id=58 的权限(包含通用权限) +curl '/api/postgrest/proxy/permissions?or=(route_id.eq.58,related_routes.cs.{58})' + +# 查询所有通用权限 +curl '/api/postgrest/proxy/permissions?related_routes=not.is.null' +``` + +--- + +## 注意事项 + +1. **通用权限只存储一份**:在 permissions 表中,通用权限只有一条记录,通过 `related_routes` 字段关联多个路由 + +2. **权限判断不依赖 route_id**:后端权限检查只看 `permission_key`,与 route_id 无关 + +3. **前端需要做 UI 同步**:当勾选/取消通用权限时,需要同步更新所有关联路由下该权限的显示状态 + +4. **保存时只提交一次**:通用权限在保存角色权限时只需要提交一次 permission_id + +5. **建议添加视觉标识**:使用颜色、图标或标签区分通用权限和独立权限,提升用户体验 + +--- + +## 方案深入分析 + +### 数据结构说明 + +``` +role_permissions 表结构: +┌─────────────────┬───────────────┬──────────────┬────────────┐ +│ role_id │ permission_id │ grant_type │ data_scope │ +├─────────────────┼───────────────┼──────────────┼────────────┤ +│ 1 (市级管理员) │ 88 │ GRANT │ ALL │ +│ 2 (普通员工) │ 88 │ GRANT │ DEPT │ +└─────────────────┴───────────────┴──────────────┴────────────┘ + +关键点:role_permissions 只关联 permission_id,不关联 route_id +``` + +### 各场景分析 + +| 场景 | 状态 | 说明 | +|------|------|------| +| 后端权限判断 | ✅ 无影响 | 基于 permission_key,不涉及 route_id | +| 权限保存 | ✅ 无影响 | 只保存 permission_id,通用权限只保存一次 | +| 数据范围 (data_scope) | ⚠️ 注意 | 同一权限只有一个数据范围,不按页面区分 | +| 按页面区分权限 | ❌ 不支持 | 同一权限无法在 A 页面有、B 页面无 | +| 前端 UI 同步 | ⚠️ 需实现 | 前端需要实现同步勾选逻辑 | + +### 场景详解 + +#### 1. 后端权限判断 - 无影响 + +```python +# 后端权限检查逻辑(permission_checker.py) +SELECT p.permission_key +FROM role_permissions rp +JOIN permissions p ON rp.permission_id = p.id +WHERE rp.role_id = {user_role_id} AND rp.grant_type = 'GRANT' +``` + +只查 `permission_key`,完全不涉及 `route_id`,所以通用权限方案不影响权限判断。 + +#### 2. 权限保存 - 无影响 + +```javascript +// 保存时只提交 permission_id +{ + "role_id": 2, + "permissions": [ + {"permission_id": 88, "grant_type": "GRANT"}, // 通用权限只保存一次 + {"permission_id": 89, "grant_type": "GRANT"} + ] +} +``` + +通用权限只有一条记录,保存一次即可,两个页面自动都有。 + +#### 3. 数据范围 (data_scope) - 需注意 + +**潜在问题**:如果同一个通用权限在不同页面需要不同的数据范围? + +| 页面 | 权限 | 期望的 data_scope | +|------|------|-------------------| +| /reviews | 查看审计状态 | DEPT(只看本部门) | +| /cross-checking/result | 查看审计状态 | ALL(看所有) | + +**当前设计**:一个权限只有一个 `data_scope`,无法按页面区分。 + +**实际业务**:通用权限操作的是同一个数据表(audit_status),数据范围应该一致,所以这个问题在当前业务场景下不存在。 + +#### 4. 按页面区分权限 - 不支持 + +**场景**:用户想要在 `/reviews` 有权限,但在 `/cross-checking/result` 没有权限。 + +**当前方案不支持**,因为是同一个 `permission_id`。 + +**但是**:从业务逻辑来看,这种需求不合理: +- 两个页面访问的是**同一个 API** +- 如果用户能在 A 页面调用 API,直接调用 API 也能成功 +- 按页面区分只是**前端显示**问题,不是**后端权限**问题 + +### 适用场景 + +**方案适用于**: +- 同一个 API 被多个页面共享 +- 不需要按页面区分权限/数据范围 +- 权限判断逻辑一致 + +**方案不适用于**: +- 需要按页面单独控制权限 +- 同一 API 在不同页面需要不同的数据范围 + +### 如果需要按页面区分权限 + +如果将来有其他共享 API 需要按页面区分权限/数据范围,则需要**创建两个独立权限**: + +```sql +-- 方案:为每个页面创建独立权限 +INSERT INTO permissions (permission_key, display_name, route_id) VALUES +('evaluation:audit_status:view:reviews', '查看审计状态(评查详情)', 58), +('evaluation:audit_status:view:crosschecking', '查看审计状态(交叉评查)', 37); +``` + +后端权限检查也需要相应修改,根据请求来源判断使用哪个 permission_key。 + +--- + +## 更新记录 + +| 日期 | 版本 | 说明 | +|------|------|------| +| 2025-12-10 | 1.0 | 初始版本,支持通用权限展示和同步勾选 | +| 2025-12-10 | 1.1 | 添加必须修改的查询方式说明、方案深入分析 | diff --git a/ecosystemDev.config.cjs b/ecosystemDev.config.cjs index 58fd1b0..bfa3311 100644 --- a/ecosystemDev.config.cjs +++ b/ecosystemDev.config.cjs @@ -1,11 +1,11 @@ // ecosystem.config.cjs - CommonJS 版本 // 多客户端部署配置:支持3个不同地区客户端通过不同端口访问 - +// testing 环境配置 module.exports = { apps: [ - // 主服务 - 生产环境 (端口: 51703) + // 主服务梅州 - 生产环境 (端口: 51703) { - name: 'docreview-main', + name: 'docreview-meizhou-client', script: 'node', args: [ '-r', 'dotenv/config', @@ -22,36 +22,36 @@ module.exports = { env: { NODE_ENV: 'testing', PORT: 5183, - CLIENT_ID: 'main', + CLIENT_ID: 'meizhou', API_PORT_CONFIG: '5183', // 添加这些环境变量确保客户端能获取到 NEXT_PUBLIC_NODE_ENV: 'testing', NEXT_PUBLIC_PORT: '5183', - NEXT_PUBLIC_CLIENT_ID: 'main', + NEXT_PUBLIC_CLIENT_ID: 'meizhou', NEXT_PUBLIC_API_PORT_CONFIG: '5183', OAUTH_CLIENT_SECRET: 'VYk1AC5XIJEfnEXwyq0u9JEY3fi3byCfSD58zANGeb' }, env_testing: { NODE_ENV: 'testing', PORT: 5183, - CLIENT_ID: 'main', + CLIENT_ID: 'meizhou', API_PORT_CONFIG: '5183', // 添加这些环境变量确保客户端能获取到 NEXT_PUBLIC_NODE_ENV: 'testing', NEXT_PUBLIC_PORT: '5183', - NEXT_PUBLIC_CLIENT_ID: 'main', + NEXT_PUBLIC_CLIENT_ID: 'meizhou', NEXT_PUBLIC_API_PORT_CONFIG: '5183', OAUTH_CLIENT_SECRET: 'VYk1AC5XIJEfnEXwyq0u9JEY3fi3byCfSD58zANGeb' }, - error_file: './logs/main-err.log', - out_file: './logs/main-out.log', - log_file: './logs/main-combined.log', + error_file: './logs/meizhou-err.log', + out_file: './logs/meizhou-out.log', + log_file: './logs/meizhou-combined.log', time: true }, - // 客户端潮州 - 反向代理服务 (端口: 51704) + // 客户端云浮 - 反向代理服务 (端口: 51704) { - name: 'docreview-client-chaozhou', + name: 'docreview-yunfu-client', script: 'node', args: [ '-r', 'dotenv/config', @@ -64,12 +64,12 @@ module.exports = { env: { NODE_ENV: 'testing', PORT: 51704, - CLIENT_ID: 'chaozhou', + CLIENT_ID: 'yunfu', API_PORT_CONFIG: '51704', // 添加这些环境变量确保客户端能获取到 NEXT_PUBLIC_NODE_ENV: 'testing', NEXT_PUBLIC_PORT: '51704', - NEXT_PUBLIC_CLIENT_ID: 'chaozhou', + NEXT_PUBLIC_CLIENT_ID: 'yunfu', NEXT_PUBLIC_API_PORT_CONFIG: '51704', // 🔒 OAuth 敏感配置 OAUTH_CLIENT_SECRET: 'VYk1AC5XIJEfnEXwyq0u9JEY3fi3byCfSD58zANGeb' @@ -77,24 +77,24 @@ module.exports = { env_testing: { NODE_ENV: 'testing', PORT: 51704, - CLIENT_ID: 'chaozhou', + CLIENT_ID: 'yunfu', API_PORT_CONFIG: '51704', // 添加这些环境变量确保客户端能获取到 NEXT_PUBLIC_NODE_ENV: 'testing', NEXT_PUBLIC_PORT: '51704', - NEXT_PUBLIC_CLIENT_ID: 'chaozhou', + NEXT_PUBLIC_CLIENT_ID: 'yunfu', NEXT_PUBLIC_API_PORT_CONFIG: '51704', // 🔒 OAuth 敏感配置 OAUTH_CLIENT_SECRET: 'VYk1AC5XIJEfnEXwyq0u9JEY3fi3byCfSD58zANGeb' }, - error_file: './logs/chaozhou-err.log', - out_file: './logs/chaozhou-out.log', - log_file: './logs/chaozhou-combined.log', + error_file: './logs/yunfu-err.log', + out_file: './logs/yunfu-out.log', + log_file: './logs/yunfu-combined.log', time: true }, // 客户端揭阳 - 独立服务 (端口: 51705) { - name: 'docreview-client-jieyang', + name: 'docreview-jieyang-client', script: 'node', args: [ '-r', 'dotenv/config', @@ -135,9 +135,9 @@ module.exports = { log_file: './logs/jieyang-combined.log', time: true }, - // 客户端云浮 - 独立服务 (端口: 51706) + // 客户端潮州 - 独立服务 (端口: 51706) { - name: 'docreview-client-yunfu', + name: 'docreview-chaozhou-client', script: 'node', args: [ '-r', 'dotenv/config', @@ -150,12 +150,12 @@ module.exports = { env: { NODE_ENV: 'testing', PORT: 51706, - CLIENT_ID: 'yunfu', + CLIENT_ID: 'chaozhou', API_PORT_CONFIG: '51706', // 添加这些环境变量确保客户端能获取到 NEXT_PUBLIC_NODE_ENV: 'testing', NEXT_PUBLIC_PORT: '51706', - NEXT_PUBLIC_CLIENT_ID: 'yunfu', + NEXT_PUBLIC_CLIENT_ID: 'chaozhou', NEXT_PUBLIC_API_PORT_CONFIG: '51706', // 🔒 OAuth 敏感配置 OAUTH_CLIENT_SECRET: 'VYk1AC5XIJEfnEXwyq0u9JEY3fi3byCfSD58zANGeb' @@ -163,24 +163,24 @@ module.exports = { env_testing: { NODE_ENV: 'testing', PORT: 51706, - CLIENT_ID: 'yunfu', + CLIENT_ID: 'chaozhou', API_PORT_CONFIG: '51706', // 添加这些环境变量确保客户端能获取到 NEXT_PUBLIC_NODE_ENV: 'testing', NEXT_PUBLIC_PORT: '51706', - NEXT_PUBLIC_CLIENT_ID: 'yunfu', + NEXT_PUBLIC_CLIENT_ID: 'chaozhou', NEXT_PUBLIC_API_PORT_CONFIG: '51706', // 🔒 OAuth 敏感配置 OAUTH_CLIENT_SECRET: 'VYk1AC5XIJEfnEXwyq0u9JEY3fi3byCfSD58zANGeb' }, - error_file: './logs/yunfu-err.log', - out_file: './logs/yunfu-out.log', - log_file: './logs/yunfu-combined.log', + error_file: './logs/chaozhou-err.log', + out_file: './logs/chaozhou-out.log', + log_file: './logs/chaozhou-combined.log', time: true }, - // 客户端梅州 - 独立服务 (端口: 51707) + // 客户端省局 - 独立服务 (端口: 51707) { - name: 'docreview-client-meizhou', + name: 'docreview-province-client', script: 'node', args: [ '-r', 'dotenv/config', @@ -193,12 +193,12 @@ module.exports = { env: { NODE_ENV: 'testing', PORT: 51707, - CLIENT_ID: 'meizhou', + CLIENT_ID: 'province', API_PORT_CONFIG: '51707', // 添加这些环境变量确保客户端能获取到 NEXT_PUBLIC_NODE_ENV: 'testing', NEXT_PUBLIC_PORT: '51707', - NEXT_PUBLIC_CLIENT_ID: 'meizhou', + NEXT_PUBLIC_CLIENT_ID: 'province', NEXT_PUBLIC_API_PORT_CONFIG: '51707', // 🔒 OAuth 敏感配置 OAUTH_CLIENT_SECRET: 'VYk1AC5XIJEfnEXwyq0u9JEY3fi3byCfSD58zANGeb' @@ -206,63 +206,63 @@ module.exports = { env_testing: { NODE_ENV: 'testing', PORT: 51707, - CLIENT_ID: 'meizhou', + CLIENT_ID: 'province', API_PORT_CONFIG: '51707', // 添加这些环境变量确保客户端能获取到 NEXT_PUBLIC_NODE_ENV: 'testing', NEXT_PUBLIC_PORT: '51707', - NEXT_PUBLIC_CLIENT_ID: 'meizhou', + NEXT_PUBLIC_CLIENT_ID: 'province', NEXT_PUBLIC_API_PORT_CONFIG: '51707', // 🔒 OAuth 敏感配置 OAUTH_CLIENT_SECRET: 'VYk1AC5XIJEfnEXwyq0u9JEY3fi3byCfSD58zANGeb' }, - error_file: './logs/meizhou-err.log', - out_file: './logs/meizhou-out.log', - log_file: './logs/meizhou-combined.log', - time: true - }, - // 客户端省局 - 独立服务 (端口: 51708) - { - name: 'docreview-client-province', - script: 'node', - args: [ - '-r', 'dotenv/config', - './server.js' // 使用自定义服务器(包含 HTTP 日志记录) - ], - instances: 1, - autorestart: true, - watch: false, - max_memory_restart: '1G', - env: { - NODE_ENV: 'testing', - PORT: 51708, - CLIENT_ID: 'province', - API_PORT_CONFIG: '51708', - // 添加这些环境变量确保客户端能获取到 - NEXT_PUBLIC_NODE_ENV: 'testing', - NEXT_PUBLIC_PORT: '51708', - NEXT_PUBLIC_CLIENT_ID: 'province', - NEXT_PUBLIC_API_PORT_CONFIG: '51708', - // 🔒 OAuth 敏感配置 - OAUTH_CLIENT_SECRET: 'VYk1AC5XIJEfnEXwyq0u9JEY3fi3byCfSD58zANGeb' - }, - env_testing: { - NODE_ENV: 'testing', - PORT: 51708, - CLIENT_ID: 'province', - API_PORT_CONFIG: '51708', - // 添加这些环境变量确保客户端能获取到 - NEXT_PUBLIC_NODE_ENV: 'testing', - NEXT_PUBLIC_PORT: '51708', - NEXT_PUBLIC_CLIENT_ID: 'province', - NEXT_PUBLIC_API_PORT_CONFIG: '51708', - // 🔒 OAuth 敏感配置 - OAUTH_CLIENT_SECRET: 'VYk1AC5XIJEfnEXwyq0u9JEY3fi3byCfSD58zANGeb' - }, error_file: './logs/province-err.log', out_file: './logs/province-out.log', log_file: './logs/province-combined.log', time: true + }, + // 客户端 - 独立服务 (端口: 51708) + { + name: 'docreview-test-client', + script: 'node', + args: [ + '-r', 'dotenv/config', + './server.js' // 使用自定义服务器(包含 HTTP 日志记录) + ], + instances: 1, + autorestart: true, + watch: false, + max_memory_restart: '1G', + env: { + NODE_ENV: 'testing', + PORT: 51708, + CLIENT_ID: 'test', + API_PORT_CONFIG: '51708', + // 添加这些环境变量确保客户端能获取到 + NEXT_PUBLIC_NODE_ENV: 'testing', + NEXT_PUBLIC_PORT: '51708', + NEXT_PUBLIC_CLIENT_ID: 'test', + NEXT_PUBLIC_API_PORT_CONFIG: '51708', + // 🔒 OAuth 敏感配置 + OAUTH_CLIENT_SECRET: 'VYk1AC5XIJEfnEXwyq0u9JEY3fi3byCfSD58zANGeb' + }, + env_testing: { + NODE_ENV: 'testing', + PORT: 51708, + CLIENT_ID: 'test', + API_PORT_CONFIG: '51708', + // 添加这些环境变量确保客户端能获取到 + NEXT_PUBLIC_NODE_ENV: 'testing', + NEXT_PUBLIC_PORT: '51708', + NEXT_PUBLIC_CLIENT_ID: 'test', + NEXT_PUBLIC_API_PORT_CONFIG: '51708', + // 🔒 OAuth 敏感配置 + OAUTH_CLIENT_SECRET: 'VYk1AC5XIJEfnEXwyq0u9JEY3fi3byCfSD58zANGeb' + }, + error_file: './logs/test-err.log', + out_file: './logs/test-out.log', + log_file: './logs/test-combined.log', + time: true } ] }; \ No newline at end of file diff --git a/package.json b/package.json index cd997e1..4e9607b 100644 --- a/package.json +++ b/package.json @@ -34,13 +34,12 @@ "axios": "^1.9.0", "chalk": "^5.3.0", "compression": "^1.7.5", - "express": "^4.21.2", - "morgan": "^1.10.0", "dayjs": "^1.11.13", "diff": "^7.0.0", "docx-preview": "^0.3.5", "docxtemplater": "^3.67.5", "dotenv": "^16.5.0", + "express": "^4.21.2", "highlight.js": "^11.11.1", "html-docx-js": "^0.3.1", "immer": "^10.1.1", @@ -50,10 +49,12 @@ "katex": "^0.16.22", "mammoth": "^1.9.0", "monaco-editor": "^0.55.1", + "morgan": "^1.10.0", "pdf-lib": "^1.17.1", "pg": "^8.14.1", "pizzip": "^3.2.0", - "pm2": "^6.0.8", + "pm2": "^6.0.14", + "pm2-logrotate": "^3.0.0", "prismjs": "^1.30.0", "react": "^18.2.0", "react-dom": "^18.2.0",