WARCH2 增加企微审批历史只读查询
这个提交包含在:
@@ -17,6 +17,7 @@ const prismaPlugin = require("./plugins/prisma");
|
||||
const swaggerDocsPlugin = require("./plugins/swagger");
|
||||
const authRoutes = require("./routes/auth");
|
||||
const wecomAuthRoutes = require("./routes/wecomAuth");
|
||||
const wecomApprovalArchiveRoutes = require("./routes/wecomApprovalArchive");
|
||||
const wecomApprovalCallbackRoutes = require("./routes/wecomApprovalCallback");
|
||||
const meRoutes = require("./routes/me");
|
||||
const reimbursementRoutes = require("./routes/reimbursements");
|
||||
@@ -337,6 +338,7 @@ async function buildApp() {
|
||||
|
||||
await app.register(authRoutes, { prefix: "/api/auth" });
|
||||
await app.register(wecomAuthRoutes, { prefix: "/api/auth" });
|
||||
await app.register(wecomApprovalArchiveRoutes, { prefix: "/api" });
|
||||
await app.register(wecomApprovalCallbackRoutes, { prefix: "/api" });
|
||||
await app.register(eventsRoutes, { prefix: "/api" });
|
||||
await app.register(meRoutes, { prefix: "/api" });
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
const { getApprovalArchiveDetail, listApprovalArchives } = require("../services/integrations/wecomApprovalArchive");
|
||||
const { AppError } = require("../utils/AppError");
|
||||
const { sendData } = require("../utils/reply");
|
||||
|
||||
const ARCHIVE_VIEW_PERMISSIONS = new Set([
|
||||
"leave.view_own",
|
||||
"attendance.view_own",
|
||||
"leave.view_all",
|
||||
"attendance.view_all",
|
||||
"*",
|
||||
]);
|
||||
|
||||
function notFound() {
|
||||
return new AppError({
|
||||
code: "SYS_404",
|
||||
message: "Not Found",
|
||||
statusCode: 404,
|
||||
});
|
||||
}
|
||||
|
||||
function hasArchiveViewPermission(employee = {}) {
|
||||
const roleCodes = employee.roleCodes || [];
|
||||
const permissionCodes = employee.permissionCodes || [];
|
||||
return roleCodes.includes("admin")
|
||||
|| roleCodes.includes("hr")
|
||||
|| permissionCodes.some((code) => ARCHIVE_VIEW_PERMISSIONS.has(code));
|
||||
}
|
||||
|
||||
function ensureArchiveViewPermission(request) {
|
||||
if (hasArchiveViewPermission(request.employee)) {
|
||||
return;
|
||||
}
|
||||
throw new AppError({
|
||||
code: "PERM_001",
|
||||
message: "无权限",
|
||||
statusCode: 403,
|
||||
details: { permission: "leave.view_own|attendance.view_own" },
|
||||
});
|
||||
}
|
||||
|
||||
async function wecomApprovalArchiveRoutes(fastify) {
|
||||
fastify.get("/wecom/approval-archive", { preHandler: fastify.authenticate }, async (request, reply) => {
|
||||
ensureArchiveViewPermission(request);
|
||||
const result = await listApprovalArchives({
|
||||
prisma: fastify.prisma,
|
||||
actor: request.employee,
|
||||
query: request.query || {},
|
||||
});
|
||||
return sendData(reply, request, result);
|
||||
});
|
||||
|
||||
fastify.get("/wecom/approval-archive/:id", { preHandler: fastify.authenticate }, async (request, reply) => {
|
||||
ensureArchiveViewPermission(request);
|
||||
const item = await getApprovalArchiveDetail({
|
||||
prisma: fastify.prisma,
|
||||
actor: request.employee,
|
||||
archiveId: request.params?.id,
|
||||
});
|
||||
if (!item) {
|
||||
throw notFound();
|
||||
}
|
||||
return sendData(reply, request, { item });
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = wecomApprovalArchiveRoutes;
|
||||
@@ -334,6 +334,314 @@ function publicArchiveSummary(detailOrRecord) {
|
||||
};
|
||||
}
|
||||
|
||||
function toIso(value) {
|
||||
if (!value) return null;
|
||||
const date = value instanceof Date ? value : new Date(value);
|
||||
return Number.isNaN(date.getTime()) ? null : date.toISOString();
|
||||
}
|
||||
|
||||
function safeDateInput(value) {
|
||||
const raw = toSafeString(value, 80);
|
||||
if (!raw) return null;
|
||||
const date = new Date(raw);
|
||||
return Number.isNaN(date.getTime()) ? null : date;
|
||||
}
|
||||
|
||||
function safePositiveInt(value, fallback, { min = 1, max = 100 } = {}) {
|
||||
const numeric = Number(value);
|
||||
if (!Number.isFinite(numeric)) return fallback;
|
||||
return Math.min(max, Math.max(min, Math.trunc(numeric)));
|
||||
}
|
||||
|
||||
function safeEnum(value, allowed, fallback) {
|
||||
const raw = toSafeString(value, 80);
|
||||
return allowed.includes(raw) ? raw : fallback;
|
||||
}
|
||||
|
||||
function safeEmployeeSummary(employee) {
|
||||
if (!employee) return null;
|
||||
return {
|
||||
id: employee.id,
|
||||
employeeNo: employee.employee_no || null,
|
||||
name: employee.name || "",
|
||||
};
|
||||
}
|
||||
|
||||
function isArchiveAdmin(actor = {}) {
|
||||
const roleCodes = actor.roleCodes || [];
|
||||
const permissionCodes = actor.permissionCodes || [];
|
||||
return roleCodes.includes("admin") || roleCodes.includes("hr") || permissionCodes.includes("*");
|
||||
}
|
||||
|
||||
function buildSelfArchiveWhere(employeeId) {
|
||||
return {
|
||||
OR: [
|
||||
{ applicant_employee_id: employeeId },
|
||||
{
|
||||
AND: [
|
||||
{ applicant_employee_id: { not: null } },
|
||||
{ participants: { some: { employee_id: employeeId } } },
|
||||
],
|
||||
},
|
||||
{
|
||||
AND: [
|
||||
{ applicant_employee_id: { not: null } },
|
||||
{ actions: { some: { operator_employee_id: employeeId } } },
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
function impossibleWhere() {
|
||||
return { id: "__not_accessible__" };
|
||||
}
|
||||
|
||||
async function resolveDirectReportIds(prisma, actor) {
|
||||
if (!prisma?.employee || !actor?.id) return [];
|
||||
const rows = await prisma.employee.findMany({
|
||||
where: { status: "active", direct_manager_id: actor.id },
|
||||
select: { id: true },
|
||||
});
|
||||
return rows.map((item) => item.id).filter(Boolean);
|
||||
}
|
||||
|
||||
function normalizeArchiveQuery(query = {}, actor = {}, directReportIds = []) {
|
||||
const requestedScope = safeEnum(query.scope, ["self", "team", "all"], "self");
|
||||
const canAll = isArchiveAdmin(actor);
|
||||
const scope = requestedScope === "all" && !canAll ? "self" : requestedScope;
|
||||
const page = safePositiveInt(query.page, 1, { min: 1, max: 10000 });
|
||||
const pageSize = safePositiveInt(query.pageSize ?? query.limit, 20, { min: 1, max: 100 });
|
||||
const type = safeEnum(query.type, ["leave", "missed_punch"], "");
|
||||
const status = safeEnum(query.status, ["pending", "approved", "rejected", "revoked", "terminated", "changed", "unknown"], "");
|
||||
const finding = safeEnum(query.finding, ["open", "resolved", "any"], "");
|
||||
const employeeId = toSafeString(query.employeeId, 80);
|
||||
const from = safeDateInput(query.from || query.startTime || query.start_time);
|
||||
const to = safeDateInput(query.to || query.endTime || query.end_time);
|
||||
const allowedApplicantIds = new Set([actor.id, ...directReportIds].filter(Boolean));
|
||||
return {
|
||||
scope,
|
||||
requestedScope,
|
||||
canAll,
|
||||
page,
|
||||
pageSize,
|
||||
type,
|
||||
status,
|
||||
finding,
|
||||
employeeId,
|
||||
from,
|
||||
to,
|
||||
allowedApplicantIds,
|
||||
directReportIds,
|
||||
};
|
||||
}
|
||||
|
||||
function buildArchiveAccessWhere({ actor, normalized }) {
|
||||
if (!actor?.id) return impossibleWhere();
|
||||
const selfWhere = buildSelfArchiveWhere(actor.id);
|
||||
if (normalized.scope === "all" && normalized.canAll) {
|
||||
return {};
|
||||
}
|
||||
if (normalized.scope === "team") {
|
||||
const teamApplicantWhere = normalized.directReportIds.length
|
||||
? { applicant_employee_id: { in: normalized.directReportIds } }
|
||||
: impossibleWhere();
|
||||
return { OR: [selfWhere, teamApplicantWhere] };
|
||||
}
|
||||
return selfWhere;
|
||||
}
|
||||
|
||||
function buildArchiveFilterWhere({ actor, normalized }) {
|
||||
const AND = [buildArchiveAccessWhere({ actor, normalized })];
|
||||
if (normalized.type) AND.push({ template_type: normalized.type });
|
||||
if (normalized.status) AND.push({ normalized_status: normalized.status });
|
||||
if (normalized.from || normalized.to) {
|
||||
AND.push({
|
||||
OR: [
|
||||
{ status_observed_at: { ...(normalized.from ? { gte: normalized.from } : {}), ...(normalized.to ? { lte: normalized.to } : {}) } },
|
||||
{ applied_at: { ...(normalized.from ? { gte: normalized.from } : {}), ...(normalized.to ? { lte: normalized.to } : {}) } },
|
||||
{ submitted_at: { ...(normalized.from ? { gte: normalized.from } : {}), ...(normalized.to ? { lte: normalized.to } : {}) } },
|
||||
],
|
||||
});
|
||||
}
|
||||
if (normalized.employeeId) {
|
||||
if (!normalized.canAll && !normalized.allowedApplicantIds.has(normalized.employeeId)) {
|
||||
AND.push(impossibleWhere());
|
||||
} else {
|
||||
AND.push({ applicant_employee_id: normalized.employeeId });
|
||||
}
|
||||
}
|
||||
if (normalized.finding === "open") {
|
||||
AND.push({ reconcile_findings: { some: { status: "open" } } });
|
||||
} else if (normalized.finding === "resolved") {
|
||||
AND.push({ reconcile_findings: { some: { status: "resolved" } } });
|
||||
} else if (normalized.finding === "any") {
|
||||
AND.push({ reconcile_findings: { some: {} } });
|
||||
}
|
||||
return AND.length === 1 ? AND[0] : { AND };
|
||||
}
|
||||
|
||||
const ARCHIVE_QUERY_INCLUDE = {
|
||||
applicant: { select: { id: true, employee_no: true, name: true } },
|
||||
participants: {
|
||||
orderBy: [{ node_index: "asc" }, { role: "asc" }],
|
||||
select: {
|
||||
id: true,
|
||||
role: true,
|
||||
node_index: true,
|
||||
employee_id: true,
|
||||
is_current: true,
|
||||
mapping_status: true,
|
||||
employee: { select: { id: true, employee_no: true, name: true } },
|
||||
},
|
||||
},
|
||||
actions: {
|
||||
orderBy: [{ action_time: "asc" }, { node_index: "asc" }],
|
||||
select: {
|
||||
id: true,
|
||||
action_type: true,
|
||||
operator_employee_id: true,
|
||||
action_time: true,
|
||||
node_index: true,
|
||||
source: true,
|
||||
operator: { select: { id: true, employee_no: true, name: true } },
|
||||
},
|
||||
},
|
||||
field_summaries: {
|
||||
orderBy: [{ field_index: "asc" }],
|
||||
select: {
|
||||
id: true,
|
||||
field_key: true,
|
||||
field_index: true,
|
||||
field_name: true,
|
||||
field_type: true,
|
||||
sensitive_level: true,
|
||||
display_allowed: true,
|
||||
},
|
||||
},
|
||||
reconcile_findings: {
|
||||
orderBy: [{ detected_at: "desc" }],
|
||||
select: {
|
||||
id: true,
|
||||
finding_type: true,
|
||||
status: true,
|
||||
severity: true,
|
||||
source: true,
|
||||
detected_at: true,
|
||||
resolved_at: true,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
function mapArchiveListItem(row) {
|
||||
return {
|
||||
id: row.id,
|
||||
spNoPrefix: maskApprovalIdentifier(row.sp_no),
|
||||
templateIdPrefix: maskApprovalIdentifier(row.template_id),
|
||||
templateType: row.template_type || "unknown",
|
||||
spName: row.sp_name || null,
|
||||
normalizedStatus: row.normalized_status || "unknown",
|
||||
applicant: safeEmployeeSummary(row.applicant),
|
||||
appliedAt: toIso(row.applied_at),
|
||||
submittedAt: toIso(row.submitted_at),
|
||||
completedAt: toIso(row.completed_at),
|
||||
statusObservedAt: toIso(row.status_observed_at),
|
||||
lastSource: row.last_source || null,
|
||||
lastCallbackAt: toIso(row.last_callback_at),
|
||||
lastCompensationAt: toIso(row.last_compensation_at),
|
||||
readabilityStatus: row.readability_status || null,
|
||||
singleTapApproveRisk: Boolean(row.single_tap_approve_risk),
|
||||
templateAdjustmentRequired: Boolean(row.template_adjustment_required),
|
||||
participantCount: row.participants?.length || 0,
|
||||
actionCount: row.actions?.length || 0,
|
||||
fieldCount: row.field_summaries?.length || 0,
|
||||
findingCount: row.reconcile_findings?.length || 0,
|
||||
openFindingCount: row.reconcile_findings?.filter((item) => item.status === "open").length || 0,
|
||||
};
|
||||
}
|
||||
|
||||
function mapArchiveDetail(row) {
|
||||
return {
|
||||
...mapArchiveListItem(row),
|
||||
participants: (row.participants || []).map((item) => ({
|
||||
id: item.id,
|
||||
role: item.role,
|
||||
nodeIndex: item.node_index,
|
||||
employee: safeEmployeeSummary(item.employee),
|
||||
isCurrent: Boolean(item.is_current),
|
||||
mappingStatus: item.employee_id ? "mapped" : item.mapping_status || "unknown",
|
||||
})),
|
||||
actions: (row.actions || []).map((item) => ({
|
||||
id: item.id,
|
||||
actionType: item.action_type,
|
||||
operator: safeEmployeeSummary(item.operator),
|
||||
actionTime: toIso(item.action_time),
|
||||
nodeIndex: item.node_index,
|
||||
source: item.source,
|
||||
})),
|
||||
fields: (row.field_summaries || []).map((item) => ({
|
||||
id: item.id,
|
||||
fieldKey: item.field_key,
|
||||
fieldIndex: item.field_index,
|
||||
fieldName: item.field_name || null,
|
||||
fieldType: item.field_type || null,
|
||||
sensitiveLevel: item.sensitive_level || "normal",
|
||||
displayAllowed: Boolean(item.display_allowed),
|
||||
})),
|
||||
findings: (row.reconcile_findings || []).map((item) => ({
|
||||
id: item.id,
|
||||
findingType: item.finding_type,
|
||||
status: item.status,
|
||||
severity: item.severity,
|
||||
source: item.source,
|
||||
detectedAt: toIso(item.detected_at),
|
||||
resolvedAt: toIso(item.resolved_at),
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
async function listApprovalArchives({ prisma, actor, query = {} }) {
|
||||
if (!prisma) throw new Error("Prisma client is required for WeCom archive query");
|
||||
const directReportIds = await resolveDirectReportIds(prisma, actor);
|
||||
const normalized = normalizeArchiveQuery(query, actor, directReportIds);
|
||||
const where = buildArchiveFilterWhere({ actor, normalized });
|
||||
const skip = (normalized.page - 1) * normalized.pageSize;
|
||||
const [total, rows] = await Promise.all([
|
||||
prisma.wecomApprovalArchive.count({ where }),
|
||||
prisma.wecomApprovalArchive.findMany({
|
||||
where,
|
||||
orderBy: [{ status_observed_at: "desc" }, { updated_at: "desc" }, { id: "desc" }],
|
||||
skip,
|
||||
take: normalized.pageSize,
|
||||
include: ARCHIVE_QUERY_INCLUDE,
|
||||
}),
|
||||
]);
|
||||
return {
|
||||
scope: normalized.scope,
|
||||
requestedScope: normalized.requestedScope,
|
||||
canViewAll: normalized.canAll,
|
||||
page: normalized.page,
|
||||
pageSize: normalized.pageSize,
|
||||
total,
|
||||
status: total > 0 ? "OK" : "PARTIAL_ARCHIVE_NEEDS_DATA",
|
||||
items: rows.map(mapArchiveListItem),
|
||||
};
|
||||
}
|
||||
|
||||
async function getApprovalArchiveDetail({ prisma, actor, archiveId }) {
|
||||
if (!prisma) throw new Error("Prisma client is required for WeCom archive query");
|
||||
const id = toSafeString(archiveId, 80);
|
||||
if (!id) return null;
|
||||
const directReportIds = await resolveDirectReportIds(prisma, actor);
|
||||
const normalized = normalizeArchiveQuery({ scope: isArchiveAdmin(actor) ? "all" : "team" }, actor, directReportIds);
|
||||
const accessWhere = buildArchiveAccessWhere({ actor, normalized });
|
||||
const row = await prisma.wecomApprovalArchive.findFirst({
|
||||
where: { AND: [{ id }, accessWhere] },
|
||||
include: ARCHIVE_QUERY_INCLUDE,
|
||||
});
|
||||
return row ? mapArchiveDetail(row) : null;
|
||||
}
|
||||
|
||||
async function mapEmployeeIdByWecomUserId(tx, externalUserId) {
|
||||
const userId = toSafeString(externalUserId, 128);
|
||||
if (!userId || !tx?.wecomIdentityBinding) return null;
|
||||
@@ -769,6 +1077,8 @@ module.exports = {
|
||||
digestObject,
|
||||
finishCompensationRun,
|
||||
isTerminalStatus,
|
||||
getApprovalArchiveDetail,
|
||||
listApprovalArchives,
|
||||
normalizeApprovalDetailPayload,
|
||||
normalizeWecomApprovalStatus,
|
||||
persistApprovalDetail,
|
||||
|
||||
@@ -0,0 +1,358 @@
|
||||
const Fastify = require("fastify");
|
||||
|
||||
const wecomApprovalArchiveRoutes = require("../../../src/routes/wecomApprovalArchive");
|
||||
const { AppError } = require("../../../src/utils/AppError");
|
||||
|
||||
const EMPLOYEE_ID = "11111111-1111-4111-8111-111111111111";
|
||||
const OTHER_ID = "22222222-2222-4222-8222-222222222222";
|
||||
const MANAGER_ID = "33333333-3333-4333-8333-333333333333";
|
||||
const REPORT_ID = "44444444-4444-4444-8444-444444444444";
|
||||
const PEER_ID = "55555555-5555-4555-8555-555555555555";
|
||||
const FINANCE_ID = "66666666-6666-4666-8666-666666666666";
|
||||
const HR_ID = "77777777-7777-4777-8777-777777777777";
|
||||
|
||||
const FULL_SP_NO = "SPWARCH2FULLIDENTIFIERSHOULDNOTLEAK";
|
||||
const FULL_TEMPLATE_ID = "TPLWARCH2FULLIDENTIFIERSHOULDNOTLEAK";
|
||||
const FULL_USER_ID = "wecom_full_userid_should_not_leak_warch2";
|
||||
const FULL_PHONE = "13900001111";
|
||||
const FULL_EMAIL = "[email protected]";
|
||||
const FULL_FIELD_VALUE = "完整请假原因不应该出现在查询响应中";
|
||||
const FULL_COMMENT = "审批评论不应该出现在查询响应中";
|
||||
const FULL_SOURCE = "raw payload must not leak";
|
||||
const FULL_ERROR = "internal processing error must not leak";
|
||||
|
||||
function employee(id, name, overrides = {}) {
|
||||
return {
|
||||
id,
|
||||
employee_no: `NO-${id.slice(0, 4)}`,
|
||||
name,
|
||||
status: "active",
|
||||
direct_manager_id: null,
|
||||
department_id: "department-a",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
const EMPLOYEES = [
|
||||
employee(EMPLOYEE_ID, "员工本人"),
|
||||
employee(OTHER_ID, "其他员工"),
|
||||
employee(MANAGER_ID, "直属主管"),
|
||||
employee(REPORT_ID, "直属下属", { direct_manager_id: MANAGER_ID }),
|
||||
employee(PEER_ID, "同部门非直属"),
|
||||
employee(FINANCE_ID, "财务"),
|
||||
employee(HR_ID, "HR"),
|
||||
];
|
||||
|
||||
function archiveRow(overrides = {}) {
|
||||
const applicantId = overrides.applicant_employee_id === undefined ? EMPLOYEE_ID : overrides.applicant_employee_id;
|
||||
const applicant = applicantId ? EMPLOYEES.find((item) => item.id === applicantId) : null;
|
||||
return {
|
||||
id: overrides.id || `archive-${Math.random().toString(36).slice(2)}`,
|
||||
sp_no: overrides.sp_no || FULL_SP_NO,
|
||||
template_id: overrides.template_id || FULL_TEMPLATE_ID,
|
||||
template_type: overrides.template_type || "leave",
|
||||
sp_name: overrides.sp_name || "OA-POC-请假",
|
||||
normalized_status: overrides.normalized_status || "approved",
|
||||
applicant_employee_id: applicantId,
|
||||
applicant_external_user_id: overrides.applicant_external_user_id || FULL_USER_ID,
|
||||
applied_at: new Date("2026-05-19T08:00:00.000Z"),
|
||||
submitted_at: new Date("2026-05-19T08:00:00.000Z"),
|
||||
completed_at: new Date("2026-05-19T09:00:00.000Z"),
|
||||
status_observed_at: new Date("2026-05-19T09:00:00.000Z"),
|
||||
last_callback_at: null,
|
||||
last_compensation_at: new Date("2026-05-19T10:00:00.000Z"),
|
||||
last_source: "compensation_pull",
|
||||
readability_status: "PASS_READABLE",
|
||||
single_tap_approve_risk: true,
|
||||
template_adjustment_required: false,
|
||||
source_summary: { raw: FULL_SOURCE },
|
||||
processing_error_message: FULL_ERROR,
|
||||
error_message: FULL_ERROR,
|
||||
applicant,
|
||||
participants: overrides.participants || [],
|
||||
actions: overrides.actions || [],
|
||||
field_summaries: overrides.field_summaries || [{
|
||||
id: `field-${overrides.id || "1"}`,
|
||||
field_key: "reason",
|
||||
field_index: 1,
|
||||
field_name: "请假原因",
|
||||
field_type: "Textarea",
|
||||
normalized_value: FULL_FIELD_VALUE,
|
||||
value_preview: FULL_FIELD_VALUE.slice(0, 10),
|
||||
sensitive_level: "restricted",
|
||||
display_allowed: false,
|
||||
}],
|
||||
attachment_summaries: [{
|
||||
id: `attach-${overrides.id || "1"}`,
|
||||
external_ref_summary: "attachment raw ref should not leak",
|
||||
}],
|
||||
reconcile_findings: overrides.reconcile_findings || [],
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function participant(employeeId, overrides = {}) {
|
||||
const linked = employeeId ? EMPLOYEES.find((item) => item.id === employeeId) : null;
|
||||
return {
|
||||
id: `participant-${employeeId || "external"}`,
|
||||
role: overrides.role || "approver",
|
||||
node_index: overrides.node_index ?? 1,
|
||||
external_user_id: overrides.external_user_id || FULL_USER_ID,
|
||||
employee_id: employeeId,
|
||||
is_current: false,
|
||||
mapping_status: employeeId ? "mapped_or_external" : "external_only",
|
||||
display_summary: FULL_USER_ID,
|
||||
employee: linked,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function action(employeeId, overrides = {}) {
|
||||
const linked = employeeId ? EMPLOYEES.find((item) => item.id === employeeId) : null;
|
||||
return {
|
||||
id: `action-${employeeId || "external"}`,
|
||||
action_type: overrides.action_type || "approve",
|
||||
operator_external_user_id: FULL_USER_ID,
|
||||
operator_employee_id: employeeId,
|
||||
action_time: new Date("2026-05-19T09:00:00.000Z"),
|
||||
node_index: 1,
|
||||
comment_summary: FULL_COMMENT,
|
||||
source: "compensation_pull",
|
||||
operator: linked,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function matchesScalar(actual, condition) {
|
||||
if (condition && typeof condition === "object" && !Array.isArray(condition)) {
|
||||
if (condition.in) return condition.in.includes(actual);
|
||||
if (Object.prototype.hasOwnProperty.call(condition, "not") && actual === condition.not) return false;
|
||||
if (condition.gte && actual < condition.gte) return false;
|
||||
if (condition.lte && actual > condition.lte) return false;
|
||||
return true;
|
||||
}
|
||||
return actual === condition;
|
||||
}
|
||||
|
||||
function matchesWhere(row, where) {
|
||||
if (!where || !Object.keys(where).length) return true;
|
||||
if (where.AND) return where.AND.every((item) => matchesWhere(row, item));
|
||||
if (where.OR) return where.OR.some((item) => matchesWhere(row, item));
|
||||
return Object.entries(where).every(([key, condition]) => {
|
||||
if (key === "participants") {
|
||||
return row.participants.some((item) => matchesWhere(item, condition.some || {}));
|
||||
}
|
||||
if (key === "actions") {
|
||||
return row.actions.some((item) => matchesWhere(item, condition.some || {}));
|
||||
}
|
||||
if (key === "reconcile_findings") {
|
||||
return row.reconcile_findings.some((item) => matchesWhere(item, condition.some || {}));
|
||||
}
|
||||
return matchesScalar(row[key], condition);
|
||||
});
|
||||
}
|
||||
|
||||
function buildPrisma(rows) {
|
||||
return {
|
||||
employee: {
|
||||
findMany: vi.fn(async ({ where }) => EMPLOYEES.filter((item) => {
|
||||
if (where?.status && item.status !== where.status) return false;
|
||||
if (where?.direct_manager_id && item.direct_manager_id !== where.direct_manager_id) return false;
|
||||
return true;
|
||||
}).map((item) => ({ id: item.id }))),
|
||||
},
|
||||
wecomApprovalArchive: {
|
||||
count: vi.fn(async ({ where }) => rows.filter((row) => matchesWhere(row, where)).length),
|
||||
findMany: vi.fn(async ({ where, skip = 0, take = 20 }) => rows.filter((row) => matchesWhere(row, where)).slice(skip, skip + take)),
|
||||
findFirst: vi.fn(async ({ where }) => rows.find((row) => matchesWhere(row, where)) || null),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function buildApp({ actor, rows }) {
|
||||
const app = Fastify();
|
||||
app.decorate("prisma", buildPrisma(rows));
|
||||
app.decorate("authenticate", async (request) => {
|
||||
request.employee = actor;
|
||||
});
|
||||
app.setErrorHandler((error, request, reply) => {
|
||||
if (error instanceof AppError) {
|
||||
return reply.status(error.statusCode).send({
|
||||
requestId: request.id,
|
||||
error: { code: error.code, message: error.message, details: error.details ?? {} },
|
||||
});
|
||||
}
|
||||
return reply.status(500).send({
|
||||
requestId: request.id,
|
||||
error: { code: "SYS_001", message: error.message || "服务器内部错误", details: {} },
|
||||
});
|
||||
});
|
||||
await app.register(wecomApprovalArchiveRoutes, { prefix: "/api" });
|
||||
return app;
|
||||
}
|
||||
|
||||
function actor(id, roleCodes = ["employee"], permissionCodes = ["leave.view_own", "attendance.view_own"]) {
|
||||
return {
|
||||
id,
|
||||
roleCodes,
|
||||
permissionCodes,
|
||||
department_id: "department-a",
|
||||
};
|
||||
}
|
||||
|
||||
function responseText(response) {
|
||||
return JSON.stringify(response.json());
|
||||
}
|
||||
|
||||
describe("wecomApprovalArchive routes", () => {
|
||||
beforeEach(() => {
|
||||
global.fetch = vi.fn(async () => {
|
||||
throw new Error("WeCom API must not be called by archive query");
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("lets normal employees see only self applicant, participant or action histories", async () => {
|
||||
const rows = [
|
||||
archiveRow({ id: "self-applicant", applicant_employee_id: EMPLOYEE_ID }),
|
||||
archiveRow({ id: "participant-related", applicant_employee_id: OTHER_ID, participants: [participant(EMPLOYEE_ID)] }),
|
||||
archiveRow({ id: "action-related", applicant_employee_id: OTHER_ID, actions: [action(EMPLOYEE_ID)] }),
|
||||
archiveRow({ id: "other-applicant", applicant_employee_id: OTHER_ID }),
|
||||
archiveRow({ id: "callback-only", applicant_employee_id: null, participants: [participant(null)], actions: [] }),
|
||||
archiveRow({ id: "unmapped-applicant-participant", applicant_employee_id: null, participants: [participant(EMPLOYEE_ID)] }),
|
||||
archiveRow({ id: "unmapped-applicant-action", applicant_employee_id: null, actions: [action(EMPLOYEE_ID)] }),
|
||||
];
|
||||
const app = await buildApp({ actor: actor(EMPLOYEE_ID), rows });
|
||||
|
||||
const response = await app.inject({ method: "GET", url: "/api/wecom/approval-archive" });
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
const ids = response.json().data.items.map((item) => item.id);
|
||||
expect(ids).toEqual(["self-applicant", "participant-related", "action-related"]);
|
||||
expect(global.fetch).not.toHaveBeenCalled();
|
||||
await app.close();
|
||||
});
|
||||
|
||||
it("requires an archive view permission even for self-related histories", async () => {
|
||||
const rows = [archiveRow({ id: "self-applicant", applicant_employee_id: EMPLOYEE_ID })];
|
||||
const app = await buildApp({ actor: actor(EMPLOYEE_ID, ["employee"], []), rows });
|
||||
|
||||
const listResponse = await app.inject({ method: "GET", url: "/api/wecom/approval-archive" });
|
||||
const detailResponse = await app.inject({ method: "GET", url: "/api/wecom/approval-archive/self-applicant" });
|
||||
|
||||
expect(listResponse.statusCode).toBe(403);
|
||||
expect(detailResponse.statusCode).toBe(403);
|
||||
await app.close();
|
||||
});
|
||||
|
||||
it("limits managers to direct-report applicant histories plus own related histories", async () => {
|
||||
const rows = [
|
||||
archiveRow({ id: "direct-report", applicant_employee_id: REPORT_ID }),
|
||||
archiveRow({ id: "manager-related", applicant_employee_id: OTHER_ID, participants: [participant(MANAGER_ID)] }),
|
||||
archiveRow({ id: "same-department-peer", applicant_employee_id: PEER_ID }),
|
||||
archiveRow({ id: "unrelated", applicant_employee_id: OTHER_ID }),
|
||||
];
|
||||
const app = await buildApp({ actor: actor(MANAGER_ID, ["manager"], ["leave.view_all", "attendance.view_all"]), rows });
|
||||
|
||||
const response = await app.inject({ method: "GET", url: "/api/wecom/approval-archive?scope=team" });
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
expect(response.json().data.items.map((item) => item.id)).toEqual(["direct-report", "manager-related"]);
|
||||
await app.close();
|
||||
});
|
||||
|
||||
it("does not treat finance leave or attendance view_all as global WeCom archive access", async () => {
|
||||
const rows = [
|
||||
archiveRow({ id: "finance-self", applicant_employee_id: FINANCE_ID }),
|
||||
archiveRow({ id: "other-applicant", applicant_employee_id: OTHER_ID }),
|
||||
];
|
||||
const app = await buildApp({
|
||||
actor: actor(FINANCE_ID, ["finance"], ["leave.view_all", "attendance.view_all"]),
|
||||
rows,
|
||||
});
|
||||
|
||||
const response = await app.inject({ method: "GET", url: "/api/wecom/approval-archive?scope=all" });
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
expect(response.json().data.scope).toBe("self");
|
||||
expect(response.json().data.items.map((item) => item.id)).toEqual(["finance-self"]);
|
||||
await app.close();
|
||||
});
|
||||
|
||||
it("allows HR/admin all-scope access including callback-only or unmapped records", async () => {
|
||||
const rows = [
|
||||
archiveRow({ id: "normal", applicant_employee_id: OTHER_ID }),
|
||||
archiveRow({ id: "callback-only", applicant_employee_id: null, participants: [participant(null)], actions: [] }),
|
||||
];
|
||||
const app = await buildApp({ actor: actor(HR_ID, ["hr"], ["leave.view_all"]), rows });
|
||||
|
||||
const response = await app.inject({ method: "GET", url: "/api/wecom/approval-archive?scope=all" });
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
expect(response.json().data.items.map((item) => item.id)).toEqual(["normal", "callback-only"]);
|
||||
await app.close();
|
||||
});
|
||||
|
||||
it("prevents employeeId filters and detail id guessing from bypassing authorization", async () => {
|
||||
const rows = [
|
||||
archiveRow({ id: "self-applicant", applicant_employee_id: EMPLOYEE_ID }),
|
||||
archiveRow({ id: "other-applicant", applicant_employee_id: OTHER_ID }),
|
||||
];
|
||||
const app = await buildApp({ actor: actor(EMPLOYEE_ID), rows });
|
||||
|
||||
const listResponse = await app.inject({ method: "GET", url: `/api/wecom/approval-archive?employeeId=${OTHER_ID}` });
|
||||
const detailResponse = await app.inject({ method: "GET", url: "/api/wecom/approval-archive/other-applicant" });
|
||||
|
||||
expect(listResponse.statusCode).toBe(200);
|
||||
expect(listResponse.json().data.items).toEqual([]);
|
||||
expect(detailResponse.statusCode).toBe(404);
|
||||
await app.close();
|
||||
});
|
||||
|
||||
it("returns whitelist-only redacted detail responses", async () => {
|
||||
const rows = [
|
||||
archiveRow({
|
||||
id: "sensitive-detail",
|
||||
applicant_employee_id: EMPLOYEE_ID,
|
||||
participants: [participant(EMPLOYEE_ID)],
|
||||
actions: [action(EMPLOYEE_ID)],
|
||||
reconcile_findings: [{
|
||||
id: "finding-1",
|
||||
finding_type: "callback_missing_for_pulled_detail",
|
||||
status: "open",
|
||||
severity: "info",
|
||||
source: "compensation_compare",
|
||||
description: FULL_FIELD_VALUE,
|
||||
detected_at: new Date("2026-05-19T11:00:00.000Z"),
|
||||
resolved_at: null,
|
||||
}],
|
||||
}),
|
||||
];
|
||||
const app = await buildApp({ actor: actor(EMPLOYEE_ID), rows });
|
||||
|
||||
const response = await app.inject({ method: "GET", url: "/api/wecom/approval-archive/sensitive-detail" });
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
const text = responseText(response);
|
||||
expect(text).not.toContain(FULL_SP_NO);
|
||||
expect(text).not.toContain(FULL_TEMPLATE_ID);
|
||||
expect(text).not.toContain(FULL_USER_ID);
|
||||
expect(text).not.toContain(FULL_PHONE);
|
||||
expect(text).not.toContain(FULL_EMAIL);
|
||||
expect(text).not.toContain(FULL_FIELD_VALUE);
|
||||
expect(text).not.toContain(FULL_COMMENT);
|
||||
expect(text).not.toContain(FULL_SOURCE);
|
||||
expect(text).not.toContain(FULL_ERROR);
|
||||
expect(text).not.toContain("normalized_value");
|
||||
expect(text).not.toContain("value_preview");
|
||||
expect(text).not.toContain("comment_summary");
|
||||
expect(text).not.toContain("source_summary");
|
||||
expect(text).not.toContain("external_ref_summary");
|
||||
expect(text).not.toContain("processing_error_message");
|
||||
expect(text).not.toContain("error_message");
|
||||
await app.close();
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,6 @@
|
||||
const {
|
||||
normalizeApprovalDetailPayload,
|
||||
listApprovalArchives,
|
||||
persistApprovalDetail,
|
||||
publicArchiveSummary,
|
||||
recordApprovalCallbackEvent,
|
||||
@@ -132,6 +133,78 @@ describe("wecomApprovalArchive", () => {
|
||||
expect(JSON.stringify(summary)).not.toContain(SENSITIVE_TEMPLATE_ID);
|
||||
});
|
||||
|
||||
it("lists query archives with whitelist-only DTO fields", async () => {
|
||||
const archive = {
|
||||
id: "archive-1",
|
||||
sp_no: SENSITIVE_SP_NO,
|
||||
template_id: SENSITIVE_TEMPLATE_ID,
|
||||
template_type: "leave",
|
||||
sp_name: "OA-POC-请假",
|
||||
normalized_status: "approved",
|
||||
applicant_employee_id: null,
|
||||
applicant_external_user_id: SENSITIVE_USER_ID,
|
||||
applied_at: new Date("2026-05-19T08:00:00.000Z"),
|
||||
submitted_at: new Date("2026-05-19T08:00:00.000Z"),
|
||||
completed_at: new Date("2026-05-19T09:00:00.000Z"),
|
||||
status_observed_at: new Date("2026-05-19T09:00:00.000Z"),
|
||||
last_callback_at: null,
|
||||
last_compensation_at: new Date("2026-05-19T10:00:00.000Z"),
|
||||
last_source: "compensation_pull",
|
||||
readability_status: "PASS_READABLE",
|
||||
single_tap_approve_risk: true,
|
||||
template_adjustment_required: false,
|
||||
source_summary: { raw: "raw payload should not leak" },
|
||||
applicant: null,
|
||||
participants: [],
|
||||
actions: [],
|
||||
field_summaries: [{
|
||||
id: "field-1",
|
||||
field_key: "reason",
|
||||
field_index: 1,
|
||||
field_name: "请假原因",
|
||||
field_type: "Textarea",
|
||||
normalized_value: SENSITIVE_REASON,
|
||||
value_preview: SENSITIVE_REASON.slice(0, 8),
|
||||
sensitive_level: "restricted",
|
||||
display_allowed: false,
|
||||
}],
|
||||
reconcile_findings: [{
|
||||
id: "finding-1",
|
||||
finding_type: "callback_missing_for_pulled_detail",
|
||||
status: "open",
|
||||
severity: "info",
|
||||
source: "compensation_compare",
|
||||
description: SENSITIVE_REASON,
|
||||
detected_at: new Date("2026-05-19T11:00:00.000Z"),
|
||||
resolved_at: null,
|
||||
}],
|
||||
};
|
||||
const prisma = {
|
||||
employee: { findMany: vi.fn(async () => []) },
|
||||
wecomApprovalArchive: {
|
||||
count: vi.fn(async () => 1),
|
||||
findMany: vi.fn(async () => [archive]),
|
||||
},
|
||||
};
|
||||
|
||||
const result = await listApprovalArchives({
|
||||
prisma,
|
||||
actor: { id: "hr-1", roleCodes: ["hr"], permissionCodes: [] },
|
||||
query: { scope: "all" },
|
||||
});
|
||||
|
||||
expect(result.items).toHaveLength(1);
|
||||
const text = JSON.stringify(result);
|
||||
expect(text).not.toContain(SENSITIVE_SP_NO);
|
||||
expect(text).not.toContain(SENSITIVE_TEMPLATE_ID);
|
||||
expect(text).not.toContain(SENSITIVE_USER_ID);
|
||||
expect(text).not.toContain(SENSITIVE_REASON);
|
||||
expect(text).not.toContain("normalized_value");
|
||||
expect(text).not.toContain("value_preview");
|
||||
expect(text).not.toContain("source_summary");
|
||||
expect(text).not.toContain("description");
|
||||
});
|
||||
|
||||
it("persists full keys internally but returns redacted output", async () => {
|
||||
const prisma = buildMockPrisma();
|
||||
const result = await persistApprovalDetail({
|
||||
|
||||
在新工单中引用
屏蔽一个用户