实现业务附件受控入库 V1(05-28-business-attachment-governance-v1)

这个提交包含在:
2026-05-28 19:44:15 +08:00
父节点 2c067866ef
当前提交 52e5284954
共修改 25 个文件,包含 1119 行新增和 21 行删除
+90
查看文件
@@ -17,3 +17,93 @@
- `.trellis/tasks/` may contain PRD and acceptance context, but only in
redacted form.
- `scripts/qa/check-trellis-governance.mjs` must pass before commit.
## Scenario: Business Attachment Governance
### 1. Scope / Trigger
- Trigger: adding or changing OA business attachments for reimbursement,
purchase, payment, contract, project or company-document workflows.
- Applies when an attachment is stored by the OA and can later be previewed,
downloaded, reviewed or archived.
- Do not introduce a generic file-center or free directory upload unless a
Trellis task explicitly reopens that architecture.
### 2. Signatures
- DB fields on business attachment models:
`governance_status`, `sensitivity_level`, `attachment_type`,
`reviewed_by`, `reviewed_at`, `review_note`, `archived_by`,
`archived_at`, `archive_note`, `download_policy`.
- Upload multipart fields may include:
`governanceStatus`, `sensitivityLevel`, `attachmentType`, `downloadPolicy`.
- API response fields must use camelCase:
`governanceStatus`, `sensitivityLevel`, `attachmentType`, `downloadPolicy`,
`reviewedBy`, `reviewedAt`, `reviewNote`, `archivedBy`, `archivedAt`,
`archiveNote`.
- Download APIs may accept `reason`; sensitive or reason-required attachments
must reject downloads without a non-empty reason.
### 3. Contracts
- Default status is `approved` unless the business workflow has a stricter
state such as `pending_review`.
- Default sensitivity is `internal`; payment receipts and similarly sensitive
financial proof should use `confidential`.
- `index_only` means OA stores the index/metadata, not the original file.
- Frontend labels are informational only; backend permission and download
reason checks remain authoritative.
- Audit logs for downloads must include actor, business target, attachment ID,
attachment type, sensitivity level, governance status and sanitized reason
when provided.
### 4. Validation & Error Matrix
- Unknown governance enum in upload -> normalize to the module default, do not
persist arbitrary strings.
- Sensitive or `reason_required` attachment download without reason ->
`400 VALID_001` with `{ reason: "required" }`.
- Missing attachment or missing stored object -> `404`.
- User lacks business read permission -> `403 PERM_001`.
- Download reason longer than the server limit -> trim before writing audit
metadata.
### 5. Good/Base/Bad Cases
- Good: a bank receipt upload returns `confidential`, `receipt`,
`reason_required`; download first asks for用途 and writes an audit log.
- Base: a reimbursement supporting material upload defaults to `internal`,
`supporting_material`, `standard` and remains downloadable by authorized
participants.
- Bad: a page hides the download button but the backend route still streams the
file without permission or reason checks.
- Bad: Enterprise WeChat messages or reports include original attachments
instead of OA links and desensitized summaries.
### 6. Tests Required
- Prisma validate after schema or migration changes.
- Backend route tests for default governance fields, custom multipart fields
when exposed, sensitive-download reason rejection and audit `extra` metadata.
- Frontend typecheck/build after API type changes.
- Focused UI verification for pages that add attachment governance labels or
download-reason interactions.
### 7. Wrong vs Correct
#### Wrong
```js
return reply.send(stream);
```
#### Correct
```js
const reason = sanitizeDownloadReason(request.query?.reason);
if (requiresDownloadReason(attachment) && !reason) {
throw buildValidationError({ reason: "required" });
}
await prisma.auditLog.create({ data: buildDownloadAudit({ attachment, reason }) });
return reply.send(stream);
```
@@ -0,0 +1,9 @@
{"file":".trellis/spec/hyb-oa/index.md","reason":"核对 hyb-oa 项目级规则"}
{"file":".trellis/spec/hyb-oa/backend-fastify-prisma.md","reason":"核对后端路由、Prisma 和错误处理"}
{"file":".trellis/spec/hyb-oa/frontend-react-vite-design.md","reason":"核对前端 UI 和设计边界"}
{"file":".trellis/spec/hyb-oa/reimbursement-invoice-ocr.md","reason":"核对报销附件和票据边界未被破坏"}
{"file":".trellis/spec/hyb-oa/security-privacy.md","reason":"核对敏感资料、日志和报告脱敏"}
{"file":".trellis/spec/backend/index.md","reason":"核对后端质量门禁"}
{"file":".trellis/spec/frontend/index.md","reason":"核对前端质量门禁"}
{"file":".trellis/tasks/05-28-business-attachment-governance-v1/prd.md","reason":"核对需求验收"}
{"file":".trellis/tasks/05-28-business-attachment-governance-v1/design.md","reason":"核对设计一致性"}
@@ -0,0 +1,84 @@
# 业务附件受控入库 V1 设计
## 实施策略
本轮采用“增强现有附件模型 + 共享常量/校验 + 下载审计”的方式,不新建独立文件中心。
原因:
- 现有业务已经有报销附件、采购附件、付款回单附件和票据池,直接增强可降低迁移风险。
- 统一大表会牵涉跨业务数据迁移、权限重写和前端重构,当前 V1 不需要一次性完成。
- 先把状态、敏感级别、附件类型和下载审计打齐,后续如需统一 `business_attachments` 视图或表,再基于真实使用频率演进。
## 数据设计
### 统一字段
优先给已有业务附件表补字段:
- `governance_status`:附件治理状态,默认 `approved` 或业务现状兼容值。
- `sensitivity_level`:敏感级别,默认 `internal`。
- `attachment_type`:附件类型,默认 `other`。
- `reviewed_by` / `reviewed_at` / `review_note`:复核信息。
- `archived_by` / `archived_at` / `archive_note`:归档信息。
- `download_policy`:下载策略,默认 `standard`。
如果某表已有相似字段,则复用或做映射,不重复制造语义。
### 迁移与回滚口径
本轮迁移只新增列和索引,不改写既有附件文件、不迁移存储位置、不删除历史数据。
如上线后必须回滚应用代码,旧代码可忽略新增列继续运行;如必须回滚数据库结构,先确认没有依赖这些字段的新代码在线,再按新增索引和新增列的反向顺序删除。本轮不执行数据库重置、不重写迁移历史。
### 下载审计
优先复用现有审计日志机制;若现有日志无法表达附件下载,则新增轻量附件下载审计表,字段至少包括:
- 操作者
- 业务类型和业务 ID
- 附件模型/附件 ID
- 动作:preview/download
- 原因或用途
- IP/User-Agent 摘要
- 时间
## API 设计
V1 不强制新建独立 `/business-attachments` API。先在现有业务 API 上增强:
- 上传接口接受可选 `attachmentType`、`sensitivityLevel`。
- 列表/详情接口返回治理字段。
- 下载接口接受可选 `reason`,敏感级别较高时要求填写。
- 服务端统一调用附件治理 helper 做状态、敏感级别和下载审计处理。
## 前端设计
遵循现有页面,不做大视觉重构:
- 附件列表增加紧凑标签:状态、类型、敏感级别。
- 下载入口对敏感附件提示填写用途或原因。
- 普通用户不展示无权限动作;服务端仍做最终校验。
- 企业微信消息仍只放 OA 链接和脱敏摘要,不携带附件。
## 权限边界
- 业务负责人、审批人、财务、合同/项目负责人按业务关系查看原文附件。
- 普通员工只能看自己相关业务或授权协作范围。
- 管理员不自动获得业务审批权;技术排障需最小化授权并留痕。
- 源码、密钥、Secret、完整配置、完整账号凭据和完整本地路径禁止入库。
## 分批落地
1. 后端 schema/helper/migration/test。
2. 报销、采购、付款回单 API 兼容增强。
3. 前端附件列表展示治理字段。
4. 下载审计与敏感下载原因。
5. 通过验证后再评估合同/项目专用附件入口和 `EA-DOC` 自动索引。
## 本轮实现记录
- 已增强 `PurchaseAttachment`、`ReimbursementAttachment`、`PaymentBatchReceiptAttachment`,补齐治理状态、敏感级别、附件类型、复核/归档和下载策略字段。
- 已新增共享附件治理 helper,统一上传字段解析、默认值映射、前后端展示标签和敏感下载用途判断。
- 已复用 `AuditLog` 记录报销附件、采购附件和付款回单下载;付款回单默认 `confidential` + `reason_required`,下载必须填写用途。
- 已在报销详情、移动报销详情、采购详情、移动采购详情和付款批次回单区显示紧凑治理标签。
@@ -0,0 +1,11 @@
{"file":".trellis/spec/hyb-oa/index.md","reason":"hyb-oa 项目级开发前置规则"}
{"file":".trellis/spec/hyb-oa/backend-fastify-prisma.md","reason":"Fastify/Prisma 后端实现和路由约定"}
{"file":".trellis/spec/hyb-oa/frontend-react-vite-design.md","reason":"React/Vite 前端页面和设计约束"}
{"file":".trellis/spec/hyb-oa/reimbursement-invoice-ocr.md","reason":"报销、票据、附件和 OCR 边界"}
{"file":".trellis/spec/hyb-oa/security-privacy.md","reason":"敏感资料、附件和日志脱敏边界"}
{"file":".trellis/spec/hyb-oa/wecom-boundary.md","reason":"企业微信消息不承载高敏附件的边界"}
{"file":".trellis/spec/backend/index.md","reason":"后端通用质量和结构规范"}
{"file":".trellis/spec/frontend/index.md","reason":"前端通用质量和结构规范"}
{"file":".trellis/tasks/05-28-business-attachment-governance-v1/prd.md","reason":"本任务需求与验收范围"}
{"file":".trellis/tasks/05-28-business-attachment-governance-v1/design.md","reason":"本任务实施设计"}
{"file":".trellis/tasks/05-28-company-docs-oa-ops-mapping/design.md","reason":"上游业务附件治理和资料边界设计"}
@@ -0,0 +1,69 @@
# 业务附件受控入库 V1
## Goal
在现有 OA 报销、采购、付款、合同/项目资料链路上建立统一的业务附件治理能力:允许授权人员上传必要原文附件,但附件必须依附具体业务对象,具备状态、敏感级别、附件类型、复核/归档口径和下载审计基础,避免把 OA 误用为网盘或全量资料库。
## What I Already Know
- 上游规划任务已确认:OA 不做全量资料原件库,业务必要原文附件可以进入授权业务流程。
- 当前 V0 暂不接 COS,继续使用部署服务器本地存储;后端已有 `STORAGE_DRIVER=local/s3` 存储抽象。
- 现有系统已有报销附件、采购附件、付款回单附件、票据池、合同台账、项目资料和审计日志基础。
- 当前主要缺口是:各业务附件字段不统一、状态/敏感级别/附件类型不足、下载控制和审计口径不一致、合同盖章归档和资料索引口径未统一。
- 脱敏不是一刀切要求;有权限人员可在 OA 业务流程内处理必要原文。脱敏主要用于普通协作、AI/Codex、企业微信消息、自动化报告、外发材料和低权限查看。
## Scope
纳入本轮 V1:
- 为已有业务附件模型补齐统一治理字段:状态、敏感级别、附件类型、复核人/时间/意见、归档人/时间、下载原因策略和文件 hash。
- 覆盖已有附件链路的最小后台能力:报销附件、采购附件、付款回单附件。
- 增加通用业务附件常量、校验和状态转换工具,避免各模块散落字符串。
- 增加下载审计记录基础,至少覆盖服务端下载动作和用途/原因字段。
- 在现有详情页或附件组件上展示状态、敏感级别和附件类型;不做大规模视觉重构。
- 保持现有上传可用,不破坏报销/OCR/采购/付款现有流程。
## Out Of Scope
暂不纳入本轮:
- 不做全站网盘、自由目录、批量下载、外链分享或对外公开链接。
- 不接 COS,不迁移已有附件文件,不改生产存储配置。
- 不做完整合同在线编辑、电子签章、复杂版本树、签出/签入。
- 不把 HR 高敏个人资料、工资社保、银行流水、源码密钥纳入普通附件区。
- 不部署、不写公网真实业务数据。
## Requirements
- R1:定义统一附件状态:`draft`、`under_approval`、`pending_review`、`approved`、`archived`、`returned`、`rejected`、`quarantined`。
- R2:定义统一敏感级别:`public`、`internal`、`confidential`、`restricted`、`index_only`。
- R3:定义统一附件类型,至少覆盖合同稿/盖章件、发票、回单、报价、验收、交付物、制度/资料、其他。
- R4:已有报销、采购、付款回单附件可保存并返回状态、敏感级别和附件类型,旧数据有兼容默认值。
- R5:下载动作必须服务端鉴权,并写审计记录,记录操作者、业务对象、附件、动作、原因或用途。
- R6:业务详情展示附件治理字段,前端按钮显隐不替代后端鉴权。
- R7:不改变现有发票 OCR、报销提交、采购附件上传、付款回单上传的主流程。
- R8:文档、测试和报告不得输出真实敏感原文、完整路径、完整账号、Secret 或原始 payload。
## Acceptance Criteria
- [x] Prisma schema 和迁移包含业务附件治理字段或等效表结构,且 `prisma validate` 通过。
- [x] 报销附件、采购附件、付款回单附件 API 返回治理字段并兼容旧记录。
- [x] 下载接口写入可查询的审计记录或复用现有审计日志记录附件下载动作。
- [x] 前端详情页/附件列表能显示状态、敏感级别、附件类型。
- [x] 新增或更新针对附件治理字段、下载审计和兼容默认值的后端测试。
- [x] 前端 typecheck 和 build 通过。
- [x] Trellis task validate、governance gate、`git diff --check` 通过。
## Test Plan
- `cd server && npm run prisma:validate`
- 运行相关后端单测:报销附件、采购附件、付款回单/付款批次、审计相关测试。
- `cd client && npm run typecheck && npm run build`
- `python3 ./.trellis/scripts/task.py validate 05-28-business-attachment-governance-v1`
- `node scripts/qa/check-trellis-governance.mjs`
- `git diff --check`
## Technical Notes
- 上游设计来源:`.trellis/tasks/05-28-company-docs-oa-ops-mapping/design.md`
- 本轮偏基础设施和现有流程增强,不做公网部署;面向用户可测前仍需另行批准部署和公网检查。
@@ -0,0 +1,30 @@
{
"id": "business-attachment-governance-v1",
"name": "business-attachment-governance-v1",
"title": "业务附件受控入库 V1",
"description": "为现有报销、采购、付款等业务附件建立状态、敏感级别、类型、复核/归档和下载审计基础。",
"status": "in_progress",
"dev_type": null,
"scope": "backend-frontend",
"package": "hyb-oa",
"priority": "P2",
"creator": "shunli",
"assignee": "shunli",
"createdAt": "2026-05-28",
"completedAt": null,
"branch": null,
"base_branch": "main",
"worktree_path": null,
"commit": null,
"pr_url": null,
"subtasks": [],
"children": [],
"parent": "05-28-company-docs-oa-ops-mapping",
"relatedFiles": [
".trellis/tasks/05-28-business-attachment-governance-v1/prd.md",
".trellis/tasks/05-28-business-attachment-governance-v1/design.md",
".trellis/tasks/05-28-company-docs-oa-ops-mapping/design.md"
],
"notes": "",
"meta": {}
}
+4 -1
查看文件
@@ -1,4 +1,5 @@
import { apiRequestBlob, apiRequestData, type ApiBlobResponse } from "./http";
import { buildAttachmentFileUrl, type AttachmentGovernanceFields } from "../lib/attachmentGovernance";
import type { RiskFlag } from "./feeControl";
export type PaymentBatchEntityType = "reimbursement" | "travel_order";
@@ -64,6 +65,7 @@ export type PaymentBatchReceiptAttachment = {
mimeType: string;
fileSize: number;
fileSha256: string;
} & AttachmentGovernanceFields & {
uploadedBy: {
id: string;
employeeNo: string;
@@ -162,8 +164,9 @@ export function paymentBatchReceiptAttachmentFileUrl(batchId: string, attachment
export async function downloadPaymentBatchReceiptAttachment(
batchId: string,
attachmentId: string,
reason?: string,
): Promise<ApiBlobResponse> {
return apiRequestBlob(paymentBatchReceiptAttachmentFileUrl(batchId, attachmentId));
return apiRequestBlob(buildAttachmentFileUrl(paymentBatchReceiptAttachmentFileUrl(batchId, attachmentId), reason));
}
export async function deletePaymentBatchReceiptAttachment(batchId: string, attachmentId: string) {
+5 -2
查看文件
@@ -1,4 +1,5 @@
import { apiRequestData } from "./http";
import type { AttachmentGovernanceFields } from "../lib/attachmentGovernance";
import type { PrecheckResponse, RiskFlag } from "./feeControl";
export type PurchaseRequestStatus = "draft" | "reviewing" | "approved" | "rejected" | "cancelled";
@@ -89,6 +90,8 @@ export type PurchaseAttachment = {
originalName: string;
mimeType: string;
fileSize: number;
fileSha256: string | null;
} & AttachmentGovernanceFields & {
uploadedBy: string;
createdAt: string | null;
uploader: {
@@ -383,12 +386,12 @@ export async function deletePurchaseAttachment(purchaseRequestId: string, attach
);
}
export async function issuePurchaseAttachmentSignedUrl(purchaseRequestId: string, attachmentId: string) {
export async function issuePurchaseAttachmentSignedUrl(purchaseRequestId: string, attachmentId: string, reason?: string) {
return apiRequestData<{ signedUrl: string; expiresAt: string }>(
`/api/purchase-requests/${purchaseRequestId}/attachments/${attachmentId}/signed-url`,
{
method: "POST",
body: JSON.stringify({}),
body: JSON.stringify(reason ? { reason } : {}),
},
);
}
+4 -2
查看文件
@@ -1,5 +1,6 @@
import { apiRequestBlob, apiRequestData } from "./http";
import type { ExpenseStandardCheck, PrecheckResponse, RiskFlag } from "./feeControl";
import { buildAttachmentFileUrl, type AttachmentGovernanceFields } from "../lib/attachmentGovernance";
export type ReimbursementStatus =
| "draft"
@@ -182,6 +183,7 @@ export type ReimbursementDetail = {
mimeType: string;
fileSize: number;
fileSha256: string;
} & AttachmentGovernanceFields & {
uploadedBy: {
id: string;
name: string;
@@ -516,8 +518,8 @@ export function reimbursementAttachmentFileUrl(reimbursementId: string, attachme
return `/api/reimbursements/${reimbursementId}/attachments/${attachmentId}/file`;
}
export async function downloadReimbursementAttachment(reimbursementId: string, attachmentId: string) {
return apiRequestBlob(reimbursementAttachmentFileUrl(reimbursementId, attachmentId));
export async function downloadReimbursementAttachment(reimbursementId: string, attachmentId: string, reason?: string) {
return apiRequestBlob(buildAttachmentFileUrl(reimbursementAttachmentFileUrl(reimbursementId, attachmentId), reason));
}
@@ -0,0 +1,46 @@
import type { ComponentPropsWithoutRef } from "react";
import {
ATTACHMENT_TYPE_LABEL,
GOVERNANCE_STATUS_LABEL,
SENSITIVITY_LEVEL_LABEL,
attachmentRequiresDownloadReason,
type AttachmentGovernanceFields,
} from "../../lib/attachmentGovernance";
import { cn } from "../../lib/utils";
function governanceStatusClass(value: string) {
if (value === "approved" || value === "archived") return "badge-success";
if (value === "pending_review" || value === "under_approval" || value === "returned") return "badge-warning";
if (value === "rejected" || value === "quarantined") return "badge-error";
return "badge-neutral";
}
function sensitivityClass(value: string) {
if (value === "restricted") return "badge-error";
if (value === "confidential") return "badge-warning";
if (value === "index_only") return "badge-neutral";
return "badge-neutral";
}
export function AttachmentGovernanceBadges({
attachment,
className,
...props
}: ComponentPropsWithoutRef<"div"> & {
attachment: Pick<AttachmentGovernanceFields, "governanceStatus" | "sensitivityLevel" | "attachmentType" | "downloadPolicy">;
}) {
return (
<div className={cn("flex flex-wrap items-center gap-1.5", className)} {...props}>
<span className={governanceStatusClass(attachment.governanceStatus)}>
{GOVERNANCE_STATUS_LABEL[attachment.governanceStatus] ?? attachment.governanceStatus}
</span>
<span className="badge-neutral">
{ATTACHMENT_TYPE_LABEL[attachment.attachmentType] ?? attachment.attachmentType}
</span>
<span className={sensitivityClass(attachment.sensitivityLevel)}>
{SENSITIVITY_LEVEL_LABEL[attachment.sensitivityLevel] ?? attachment.sensitivityLevel}
</span>
{attachmentRequiresDownloadReason(attachment) ? <span className="badge-warning">需填用途</span> : null}
</div>
);
}
@@ -0,0 +1,96 @@
import { useCallback, useRef, useState } from "react";
import {
attachmentRequiresDownloadReason,
type AttachmentGovernanceFields,
} from "../../lib/attachmentGovernance";
import { Button } from "../ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "../ui/dialog";
import { Textarea } from "../ui/textarea";
type DownloadReasonTarget = Pick<AttachmentGovernanceFields, "downloadPolicy" | "sensitivityLevel"> & {
originalName?: string;
};
export function useAttachmentDownloadReasonDialog() {
const [target, setTarget] = useState<DownloadReasonTarget | null>(null);
const [reason, setReason] = useState("业务复核");
const [error, setError] = useState("");
const resolverRef = useRef<((reason: string | null | undefined) => void) | null>(null);
const closeWith = useCallback((value: string | null | undefined) => {
resolverRef.current?.(value);
resolverRef.current = null;
setTarget(null);
setReason("业务复核");
setError("");
}, []);
const requestDownloadReason = useCallback((attachment: DownloadReasonTarget) => {
if (!attachmentRequiresDownloadReason(attachment)) {
return Promise.resolve(undefined);
}
setTarget(attachment);
setReason("业务复核");
setError("");
return new Promise<string | null | undefined>((resolve) => {
resolverRef.current = resolve;
});
}, []);
const confirm = useCallback(() => {
const trimmed = reason.trim();
if (!trimmed) {
setError("请填写下载用途");
return;
}
closeWith(trimmed);
}, [closeWith, reason]);
const downloadReasonDialog = (
<Dialog open={Boolean(target)} onOpenChange={(open) => { if (!open) closeWith(null); }}>
<DialogContent className="max-w-md p-5">
<DialogHeader>
<DialogTitle>填写下载用途</DialogTitle>
<DialogDescription>
{target?.originalName ? `附件:${target.originalName}` : "该附件需要用途留痕后才能下载。"}
</DialogDescription>
</DialogHeader>
<div className="mt-4 space-y-2">
<Textarea
value={reason}
onChange={(event) => {
setReason(event.target.value);
if (error) setError("");
}}
aria-label="下载用途"
rows={3}
maxLength={200}
placeholder="例如:付款复核、审批查验、归档核对"
/>
<div className="flex items-center justify-between text-xs">
<span className={error ? "text-red-600" : "text-zinc-500"}>{error || "用途会写入下载审计日志。"}</span>
<span className="text-zinc-400">{reason.trim().length}/200</span>
</div>
</div>
<DialogFooter>
<Button type="button" variant="secondary" onClick={() => closeWith(null)}>
取消
</Button>
<Button type="button" onClick={confirm}>
继续下载
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
return { requestDownloadReason, downloadReasonDialog };
}
+91
查看文件
@@ -0,0 +1,91 @@
export type AttachmentGovernanceStatus =
| "draft"
| "under_approval"
| "pending_review"
| "approved"
| "archived"
| "returned"
| "rejected"
| "quarantined";
export type AttachmentSensitivityLevel =
| "public"
| "internal"
| "confidential"
| "restricted"
| "index_only";
export type BusinessAttachmentType =
| "contract_draft"
| "contract_stamped"
| "invoice"
| "receipt"
| "quote"
| "acceptance"
| "delivery"
| "policy_document"
| "supporting_material"
| "other";
export type AttachmentDownloadPolicy = "standard" | "reason_required" | "restricted";
export type AttachmentGovernanceFields = {
governanceStatus: AttachmentGovernanceStatus | string;
sensitivityLevel: AttachmentSensitivityLevel | string;
attachmentType: BusinessAttachmentType | string;
downloadPolicy: AttachmentDownloadPolicy | string;
reviewedBy: string | null;
reviewedAt: string | null;
reviewNote: string | null;
archivedBy: string | null;
archivedAt: string | null;
archiveNote: string | null;
};
export const GOVERNANCE_STATUS_LABEL: Record<string, string> = {
draft: "草稿",
under_approval: "审批中",
pending_review: "待复核",
approved: "已通过",
archived: "已归档",
returned: "已退回",
rejected: "已拒绝",
quarantined: "已隔离",
};
export const SENSITIVITY_LEVEL_LABEL: Record<string, string> = {
public: "公开",
internal: "内部",
confidential: "保密",
restricted: "受限",
index_only: "仅索引",
};
export const ATTACHMENT_TYPE_LABEL: Record<string, string> = {
contract_draft: "合同稿",
contract_stamped: "盖章件",
invoice: "发票",
receipt: "回单",
quote: "报价",
acceptance: "验收",
delivery: "交付物",
policy_document: "制度资料",
supporting_material: "补充材料",
other: "其他",
};
export function attachmentRequiresDownloadReason(attachment: Pick<AttachmentGovernanceFields, "downloadPolicy" | "sensitivityLevel">) {
return (
attachment.downloadPolicy === "reason_required" ||
attachment.downloadPolicy === "restricted" ||
attachment.sensitivityLevel === "confidential" ||
attachment.sensitivityLevel === "restricted"
);
}
export function buildAttachmentFileUrl(path: string, reason?: string) {
const trimmed = reason?.trim();
if (!trimmed) return path;
const params = new URLSearchParams({ reason: trimmed });
return `${path}${path.includes("?") ? "&" : "?"}${params.toString()}`;
}
@@ -2,6 +2,7 @@ import { useParams } from "react-router-dom";
import { useQuery } from "@tanstack/react-query";
import { fetchPurchaseRequestDetail } from "../api/purchaseRequests";
import { MobileDetailHeader } from "../components/mobile/MobileDetailHeader";
import { AttachmentGovernanceBadges } from "../components/common/AttachmentGovernanceBadges";
import { StatusBadge } from "../components/common/StatusBadge";
import { PageScaffold } from "../components/patterns/PageScaffold";
import {
@@ -161,6 +162,7 @@ export function MobilePurchaseRequestDetailPage() {
<div className="mt-1 text-xs text-zinc-500">
{joinBusinessMeta([formatBytes(attachment.fileSize), attachment.uploader?.name, formatDate(attachment.createdAt)])}
</div>
<AttachmentGovernanceBadges attachment={attachment} className="mt-2" />
</article>
)) : <div className="mobile-empty-state">暂无附件</div>}
</div>
@@ -31,6 +31,8 @@ import { remindWorkflowTask } from "../api/workflow";
import type { PrecheckResponse } from "../api/feeControl";
import { useAuth } from "../auth/AuthContext";
import { hasPermission } from "../auth/permissions";
import { AttachmentGovernanceBadges } from "../components/common/AttachmentGovernanceBadges";
import { useAttachmentDownloadReasonDialog } from "../components/common/useAttachmentDownloadReasonDialog";
import { ConfirmDialog } from "../components/common/ConfirmDialog";
import { MobileIcon } from "../components/mobile/MobileIcon";
import { MobileDetailHeader } from "../components/mobile/MobileDetailHeader";
@@ -55,6 +57,7 @@ import {
} from "../lib/invoiceStatus";
import { resolveErrorMessage } from "../lib/resolveErrorMessage";
import { resolveReimbursementDisplayCode } from "../lib/reimbursementDisplay";
import type { AttachmentGovernanceFields } from "../lib/attachmentGovernance";
import { TEST_IDS } from "../lib/testIds";
import { withUploadTimeout } from "../lib/uploadTimeout";
import { useInvoiceOcrTracker } from "../hooks/useInvoiceOcrTracker";
@@ -671,8 +674,11 @@ export function MobileReimbursementDetailPage() {
},
});
const { requestDownloadReason, downloadReasonDialog } = useAttachmentDownloadReasonDialog();
const openAttachmentMutation = useMutation({
mutationFn: (attachmentId: string) => downloadReimbursementAttachment(reimbursementId, attachmentId),
mutationFn: ({ attachmentId, reason }: { attachmentId: string; reason?: string }) =>
downloadReimbursementAttachment(reimbursementId, attachmentId, reason),
onSuccess: (result) => {
const url = URL.createObjectURL(result.blob);
window.open(url, "_blank", "noopener,noreferrer");
@@ -683,6 +689,14 @@ export function MobileReimbursementDetailPage() {
},
});
async function handleOpenAttachment(attachment: { id: string; originalName?: string } & AttachmentGovernanceFields) {
const reason = await requestDownloadReason(attachment);
if (reason === null) {
return;
}
openAttachmentMutation.mutate({ attachmentId: attachment.id, reason });
}
const handleSubmit = async () => {
if (!detail) {
return;
@@ -811,6 +825,7 @@ export function MobileReimbursementDetailPage() {
)}
data-testid="mobile-reimbursement-detail"
>
{downloadReasonDialog}
<MobileDetailHeader
title={displayCode}
subtitle="报销详情"
@@ -1078,6 +1093,7 @@ export function MobileReimbursementDetailPage() {
<div className="mt-1 text-xs text-zinc-500">
{Math.ceil(attachment.fileSize / 1024)} KB · {formatDateTime(attachment.createdAt)}
</div>
<AttachmentGovernanceBadges attachment={attachment} className="mt-2" />
</div>
<div className="mobile-two-action-grid mt-3">
<Button
@@ -1085,7 +1101,7 @@ export function MobileReimbursementDetailPage() {
variant="secondary"
type="button"
disabled={openAttachmentMutation.isPending}
onClick={() => openAttachmentMutation.mutate(attachment.id)}
onClick={() => void handleOpenAttachment(attachment)}
>
查看
</Button>
+17 -3
查看文件
@@ -23,6 +23,8 @@ import { useAuth } from "../auth/AuthContext";
import { hasPermission } from "../auth/permissions";
import { ListWorkspace, WorkspaceHeader } from "../components/patterns/ListWorkspace";
import { ActionConfirmDialog } from "../components/common/ActionConfirmDialog";
import { AttachmentGovernanceBadges } from "../components/common/AttachmentGovernanceBadges";
import { useAttachmentDownloadReasonDialog } from "../components/common/useAttachmentDownloadReasonDialog";
import { Button } from "../components/ui/button";
import { Input } from "../components/ui/input";
import { Label } from "../components/ui/label";
@@ -301,12 +303,14 @@ export function PaymentBatchesPage() {
},
});
const { requestDownloadReason, downloadReasonDialog } = useAttachmentDownloadReasonDialog();
const downloadReceiptMutation = useMutation({
mutationFn: async (attachment: PaymentBatchReceiptAttachment) => {
mutationFn: async ({ attachment, reason }: { attachment: PaymentBatchReceiptAttachment; reason?: string }) => {
if (!selectedBatch) {
throw new Error("请先选择付款批次");
}
const response = await downloadPaymentBatchReceiptAttachment(selectedBatch.id, attachment.id);
const response = await downloadPaymentBatchReceiptAttachment(selectedBatch.id, attachment.id, reason);
return { response, attachment };
},
onSuccess: ({ response, attachment }) => {
@@ -319,6 +323,14 @@ export function PaymentBatchesPage() {
},
});
async function handleDownloadReceipt(attachment: PaymentBatchReceiptAttachment) {
const reason = await requestDownloadReason(attachment);
if (reason === null) {
return;
}
downloadReceiptMutation.mutate({ attachment, reason });
}
const deleteReceiptMutation = useMutation({
mutationFn: async (attachmentId: string) => {
if (!selectedBatch) {
@@ -340,6 +352,7 @@ export function PaymentBatchesPage() {
return (
<ListWorkspace>
{downloadReasonDialog}
<WorkspaceHeader title="付款批次" subtitle="复核、回单和已付款标记集中处理。" />
{error ? <div className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-600">{error}</div> : null}
@@ -607,6 +620,7 @@ export function PaymentBatchesPage() {
{attachment.mimeType} · {formatFileSize(attachment.fileSize)} · {formatDisplayDateTime(attachment.createdAt)}
{attachment.uploadedBy ? ` · ${attachment.uploadedBy.name}` : ""}
</div>
<AttachmentGovernanceBadges attachment={attachment} className="mt-2" />
</div>
<div className="flex flex-wrap gap-2">
<Button
@@ -614,7 +628,7 @@ export function PaymentBatchesPage() {
size="sm"
variant="secondary"
disabled={downloadReceiptMutation.isPending}
onClick={() => downloadReceiptMutation.mutate(attachment)}
onClick={() => void handleDownloadReceipt(attachment)}
>
下载
</Button>
+16 -4
查看文件
@@ -39,6 +39,8 @@ import { hasPermission } from "../auth/permissions";
import { RiskFlagsCard } from "../components/common/RiskFlagsCard";
import { AiSuggestionCard } from "../components/ai/AiSuggestionCard";
import { ActionConfirmDialog } from "../components/common/ActionConfirmDialog";
import { AttachmentGovernanceBadges } from "../components/common/AttachmentGovernanceBadges";
import { useAttachmentDownloadReasonDialog } from "../components/common/useAttachmentDownloadReasonDialog";
import { DateTextInput } from "../components/common/DateTextInput";
import { Pagination } from "../components/common/Pagination";
import { StatusBadge } from "../components/common/StatusBadge";
@@ -49,6 +51,7 @@ import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from ".
import { Textarea } from "../components/ui/textarea";
import { FilterBar, ListWorkspace, PrimaryTable, WorkspaceHeader } from "../components/patterns/ListWorkspace";
import { SectionPanel } from "../components/patterns/SectionPanel";
import type { AttachmentGovernanceFields } from "../lib/attachmentGovernance";
import { formatCompactIdentifier, formatDisplayDateTime, formatTextWithCompactLongNumbers, isCompactedIdentifier } from "../lib/displayFormat";
import { buildPrecheckDialogMessage } from "../lib/precheck";
import {
@@ -744,12 +747,18 @@ export function PurchaseRequestsPage() {
},
});
async function handleDownloadAttachment(attachmentId: string) {
const { requestDownloadReason, downloadReasonDialog } = useAttachmentDownloadReasonDialog();
async function handleDownloadAttachment(attachment: { id: string; originalName?: string } & AttachmentGovernanceFields) {
if (!selectedId) {
return;
}
const reason = await requestDownloadReason(attachment);
if (reason === null) {
return;
}
try {
const response = await issuePurchaseAttachmentSignedUrl(selectedId, attachmentId);
const response = await issuePurchaseAttachmentSignedUrl(selectedId, attachment.id, reason);
window.open(response.signedUrl, "_blank", "noopener,noreferrer");
} catch (error) {
const message = resolveError(error);
@@ -814,6 +823,7 @@ export function PurchaseRequestsPage() {
return (
<ListWorkspace>
{downloadReasonDialog}
<WorkspaceHeader
title="采购申请"
subtitle="草稿、附件、审批。"
@@ -1576,6 +1586,7 @@ export function PurchaseRequestsPage() {
<div key={attachment.id} className="rounded-lg border border-zinc-200 bg-white p-4">
<div className="font-medium text-zinc-900">{attachment.originalName}</div>
<div className="mt-1 text-xs text-zinc-500">{attachment.mimeType} / {attachment.fileSize} bytes</div>
<AttachmentGovernanceBadges attachment={attachment} className="mt-2" />
<div className="mt-3 grid grid-cols-2 gap-2 text-xs text-zinc-600">
<div>
<div className="text-zinc-400">上传人</div>
@@ -1589,7 +1600,7 @@ export function PurchaseRequestsPage() {
</div>
</div>
<div className="mt-3 flex flex-wrap gap-2">
<Button type="button" size="sm" variant="secondary" onClick={() => void handleDownloadAttachment(attachment.id)}>
<Button type="button" size="sm" variant="secondary" onClick={() => void handleDownloadAttachment(attachment)}>
下载
</Button>
<Button
@@ -1625,12 +1636,13 @@ export function PurchaseRequestsPage() {
<TableCell>
<div className="font-medium text-zinc-900">{attachment.originalName}</div>
<div className="text-xs text-zinc-500">{attachment.mimeType} / {attachment.fileSize} bytes</div>
<AttachmentGovernanceBadges attachment={attachment} className="mt-2" />
</TableCell>
<TableCell>{attachment.uploader?.name ?? "-"}</TableCell>
<TableCell>{formatDisplayDateTime(attachment.createdAt)}</TableCell>
<TableCell>
<div className="flex gap-2">
<Button type="button" size="sm" variant="secondary" onClick={() => void handleDownloadAttachment(attachment.id)}>
<Button type="button" size="sm" variant="secondary" onClick={() => void handleDownloadAttachment(attachment)}>
下载
</Button>
<Button
+18 -2
查看文件
@@ -35,6 +35,8 @@ import { useAuth } from "../auth/AuthContext";
import { hasPermission } from "../auth/permissions";
import { CardSkeleton } from "../components/common/CardSkeleton";
import { ConfirmDialog } from "../components/common/ConfirmDialog";
import { AttachmentGovernanceBadges } from "../components/common/AttachmentGovernanceBadges";
import { useAttachmentDownloadReasonDialog } from "../components/common/useAttachmentDownloadReasonDialog";
import { RiskFlagsCard } from "../components/common/RiskFlagsCard";
import { StatusBadge } from "../components/common/StatusBadge";
import { AiSuggestionCard } from "../components/ai/AiSuggestionCard";
@@ -62,6 +64,7 @@ import {
} from "../lib/invoiceStatus";
import { useInvoiceOcrTracker } from "../hooks/useInvoiceOcrTracker";
import { resolveReimbursementDisplayCode } from "../lib/reimbursementDisplay";
import type { AttachmentGovernanceFields } from "../lib/attachmentGovernance";
import { formatDisplayDate } from "../lib/displayFormat";
import { TEST_IDS } from "../lib/testIds";
import { PDF_FILE_ACCEPT, rejectedPdfMessage, splitPdfFiles } from "../lib/pdfFiles";
@@ -726,8 +729,11 @@ export function ReimbursementDetailPage() {
},
});
const { requestDownloadReason, downloadReasonDialog } = useAttachmentDownloadReasonDialog();
const openAttachmentMutation = useMutation({
mutationFn: (attachmentId: string) => downloadReimbursementAttachment(reimbursementId, attachmentId),
mutationFn: ({ attachmentId, reason }: { attachmentId: string; reason?: string }) =>
downloadReimbursementAttachment(reimbursementId, attachmentId, reason),
onSuccess: (result) => {
const url = URL.createObjectURL(result.blob);
window.open(url, "_blank", "noopener,noreferrer");
@@ -738,6 +744,14 @@ export function ReimbursementDetailPage() {
},
});
async function handleOpenAttachment(attachment: { id: string; originalName?: string } & AttachmentGovernanceFields) {
const reason = await requestDownloadReason(attachment);
if (reason === null) {
return;
}
openAttachmentMutation.mutate({ attachmentId: attachment.id, reason });
}
if (!reimbursementId) {
return (
<div className="card-std p-5">
@@ -959,6 +973,7 @@ export function ReimbursementDetailPage() {
return (
<PageScaffold>
{downloadReasonDialog}
<EntityOverviewHero
title={`报销详情 ${displayCode}`}
subtitle="状态、票据、审批和付款集中处理。"
@@ -1197,6 +1212,7 @@ export function ReimbursementDetailPage() {
<div className="text-xs text-zinc-500">
{Math.ceil(attachment.fileSize / 1024)} KB · {formatDateTime(attachment.createdAt)}
</div>
<AttachmentGovernanceBadges attachment={attachment} className="mt-2" />
</div>
<div className="flex gap-2">
<Button
@@ -1204,7 +1220,7 @@ export function ReimbursementDetailPage() {
variant="secondary"
size="sm"
disabled={openAttachmentMutation.isPending}
onClick={() => openAttachmentMutation.mutate(attachment.id)}
onClick={() => void handleOpenAttachment(attachment)}
>
查看
</Button>
@@ -0,0 +1,46 @@
ALTER TABLE "purchase_attachments"
ADD COLUMN "file_sha256" TEXT,
ADD COLUMN "governance_status" VARCHAR(32) NOT NULL DEFAULT 'approved',
ADD COLUMN "sensitivity_level" VARCHAR(32) NOT NULL DEFAULT 'internal',
ADD COLUMN "attachment_type" VARCHAR(64) NOT NULL DEFAULT 'other',
ADD COLUMN "reviewed_by" UUID,
ADD COLUMN "reviewed_at" TIMESTAMP(3),
ADD COLUMN "review_note" TEXT,
ADD COLUMN "archived_by" UUID,
ADD COLUMN "archived_at" TIMESTAMP(3),
ADD COLUMN "archive_note" TEXT,
ADD COLUMN "download_policy" VARCHAR(32) NOT NULL DEFAULT 'standard';
ALTER TABLE "reimbursement_attachments"
ADD COLUMN "governance_status" VARCHAR(32) NOT NULL DEFAULT 'approved',
ADD COLUMN "sensitivity_level" VARCHAR(32) NOT NULL DEFAULT 'internal',
ADD COLUMN "attachment_type" VARCHAR(64) NOT NULL DEFAULT 'supporting_material',
ADD COLUMN "reviewed_by" UUID,
ADD COLUMN "reviewed_at" TIMESTAMP(3),
ADD COLUMN "review_note" TEXT,
ADD COLUMN "archived_by" UUID,
ADD COLUMN "archived_at" TIMESTAMP(3),
ADD COLUMN "archive_note" TEXT,
ADD COLUMN "download_policy" VARCHAR(32) NOT NULL DEFAULT 'standard';
ALTER TABLE "payment_batch_receipt_attachments"
ADD COLUMN "governance_status" VARCHAR(32) NOT NULL DEFAULT 'approved',
ADD COLUMN "sensitivity_level" VARCHAR(32) NOT NULL DEFAULT 'confidential',
ADD COLUMN "attachment_type" VARCHAR(64) NOT NULL DEFAULT 'receipt',
ADD COLUMN "reviewed_by" UUID,
ADD COLUMN "reviewed_at" TIMESTAMP(3),
ADD COLUMN "review_note" TEXT,
ADD COLUMN "archived_by" UUID,
ADD COLUMN "archived_at" TIMESTAMP(3),
ADD COLUMN "archive_note" TEXT,
ADD COLUMN "download_policy" VARCHAR(32) NOT NULL DEFAULT 'reason_required';
CREATE INDEX "idx_purchase_attachments_file_sha256" ON "purchase_attachments"("file_sha256");
CREATE INDEX "idx_purchase_attachments_governance_status" ON "purchase_attachments"("governance_status");
CREATE INDEX "idx_purchase_attachments_sensitivity_level" ON "purchase_attachments"("sensitivity_level");
CREATE INDEX "idx_reimbursement_attachments_governance_status" ON "reimbursement_attachments"("governance_status");
CREATE INDEX "idx_reimbursement_attachments_sensitivity_level" ON "reimbursement_attachments"("sensitivity_level");
CREATE INDEX "idx_payment_batch_receipts_governance_status" ON "payment_batch_receipt_attachments"("governance_status");
CREATE INDEX "idx_payment_batch_receipts_sensitivity_level" ON "payment_batch_receipt_attachments"("sensitivity_level");
+38
查看文件
@@ -1183,14 +1183,28 @@ model PurchaseAttachment {
original_name String
mime_type String
file_size Int
file_sha256 String?
uploaded_by String @db.Uuid
created_at DateTime @default(now())
governance_status String @default("approved") @db.VarChar(32)
sensitivity_level String @default("internal") @db.VarChar(32)
attachment_type String @default("other") @db.VarChar(64)
reviewed_by String? @db.Uuid
reviewed_at DateTime?
review_note String?
archived_by String? @db.Uuid
archived_at DateTime?
archive_note String?
download_policy String @default("standard") @db.VarChar(32)
purchase_request PurchaseRequest @relation(fields: [purchase_request_id], references: [id], onDelete: Cascade)
uploader Employee @relation("PurchaseAttachmentUploader", fields: [uploaded_by], references: [id], onDelete: Restrict)
@@index([purchase_request_id, created_at], map: "idx_purchase_attachments_request_created")
@@index([uploaded_by], map: "idx_purchase_attachments_uploader")
@@index([file_sha256], map: "idx_purchase_attachments_file_sha256")
@@index([governance_status], map: "idx_purchase_attachments_governance_status")
@@index([sensitivity_level], map: "idx_purchase_attachments_sensitivity_level")
@@map("purchase_attachments")
}
@@ -1446,6 +1460,16 @@ model ReimbursementAttachment {
file_sha256 String
uploaded_by String @db.Uuid
created_at DateTime @default(now())
governance_status String @default("approved") @db.VarChar(32)
sensitivity_level String @default("internal") @db.VarChar(32)
attachment_type String @default("supporting_material") @db.VarChar(64)
reviewed_by String? @db.Uuid
reviewed_at DateTime?
review_note String?
archived_by String? @db.Uuid
archived_at DateTime?
archive_note String?
download_policy String @default("standard") @db.VarChar(32)
reimbursement_form ReimbursementForm @relation(fields: [reimbursement_id], references: [id], onDelete: Cascade)
uploader Employee @relation("ReimbursementAttachmentUploader", fields: [uploaded_by], references: [id], onDelete: Restrict)
@@ -1453,6 +1477,8 @@ model ReimbursementAttachment {
@@index([reimbursement_id, created_at], map: "idx_reimbursement_attachments_form_created")
@@index([uploaded_by], map: "idx_reimbursement_attachments_uploader")
@@index([file_sha256], map: "idx_reimbursement_attachments_file_sha256")
@@index([governance_status], map: "idx_reimbursement_attachments_governance_status")
@@index([sensitivity_level], map: "idx_reimbursement_attachments_sensitivity_level")
@@map("reimbursement_attachments")
}
@@ -1867,6 +1893,16 @@ model PaymentBatchReceiptAttachment {
file_sha256 String
uploaded_by String @db.Uuid
created_at DateTime @default(now())
governance_status String @default("approved") @db.VarChar(32)
sensitivity_level String @default("confidential") @db.VarChar(32)
attachment_type String @default("receipt") @db.VarChar(64)
reviewed_by String? @db.Uuid
reviewed_at DateTime?
review_note String?
archived_by String? @db.Uuid
archived_at DateTime?
archive_note String?
download_policy String @default("reason_required") @db.VarChar(32)
payment_batch PaymentBatch @relation(fields: [payment_batch_id], references: [id], onDelete: Cascade)
uploader Employee @relation("PaymentBatchReceiptAttachmentUploader", fields: [uploaded_by], references: [id], onDelete: Restrict)
@@ -1874,6 +1910,8 @@ model PaymentBatchReceiptAttachment {
@@index([payment_batch_id, created_at], map: "idx_payment_batch_receipts_batch_created")
@@index([uploaded_by], map: "idx_payment_batch_receipts_uploader")
@@index([file_sha256], map: "idx_payment_batch_receipts_file_sha256")
@@index([governance_status], map: "idx_payment_batch_receipts_governance_status")
@@index([sensitivity_level], map: "idx_payment_batch_receipts_sensitivity_level")
@@map("payment_batch_receipt_attachments")
}
+64 -2
查看文件
@@ -16,6 +16,16 @@ const { flushNotificationDispatchTasks } = require("../services/notificationDisp
const { enqueueReimbursementJournalExport } = require("../services/integrations/erpJournal");
const { ensureRiskFlag, buildPersistedRiskFlag, toMoneyNumber, toMoneyString } = require("../services/feeControl");
const { openReadStream, StorageNotFoundError, writeBuffer } = require("../services/storage");
const {
ATTACHMENT_TYPE,
DOWNLOAD_POLICY,
SENSITIVITY_LEVEL,
governanceCreateData,
mapGovernanceFields,
parseMultipartGovernanceField,
requiresDownloadReason,
sanitizeDownloadReason,
} = require("../services/businessAttachmentGovernance");
const UUID_SCHEMA = z.string().uuid();
const ENTITY_TYPE_SCHEMA = z.enum(["reimbursement", "travel_order"]);
@@ -99,6 +109,12 @@ function mapReceiptAttachment(item) {
mimeType: item.mime_type,
fileSize: item.file_size,
fileSha256: item.file_sha256,
...mapGovernanceFields(item, {
governanceStatus: "approved",
sensitivityLevel: SENSITIVITY_LEVEL.confidential,
attachmentType: ATTACHMENT_TYPE.receipt,
downloadPolicy: DOWNLOAD_POLICY.reasonRequired,
}),
uploadedBy: item.uploader
? {
id: item.uploader.id,
@@ -681,8 +697,13 @@ function buildNotFoundError() {
async function readReceiptUploadFiles(fastify, request) {
const files = [];
let governanceFields = {};
try {
for await (const part of request.parts()) {
if (part.type === "field") {
governanceFields = parseMultipartGovernanceField(part, governanceFields);
continue;
}
if (part.type !== "file") {
continue;
}
@@ -725,7 +746,7 @@ async function readReceiptUploadFiles(fastify, request) {
throw buildValidationError({ file: "missing" });
}
return files;
return files.map((file) => ({ ...file, governanceFields }));
}
async function paymentBatchesRoutes(fastify) {
@@ -841,6 +862,16 @@ async function paymentBatchesRoutes(fastify) {
mime_type: true,
file_size: true,
file_sha256: true,
governance_status: true,
sensitivity_level: true,
attachment_type: true,
reviewed_by: true,
reviewed_at: true,
review_note: true,
archived_by: true,
archived_at: true,
archive_note: true,
download_policy: true,
created_at: true,
uploader: {
select: {
@@ -888,6 +919,12 @@ async function paymentBatchesRoutes(fastify) {
file_size: file.fileSize,
file_sha256: fileSha256,
uploaded_by: request.employee.id,
...governanceCreateData(file.governanceFields, {
governanceStatus: "approved",
sensitivityLevel: SENSITIVITY_LEVEL.confidential,
attachmentType: ATTACHMENT_TYPE.receipt,
downloadPolicy: DOWNLOAD_POLICY.reasonRequired,
}),
},
select: {
id: true,
@@ -895,6 +932,16 @@ async function paymentBatchesRoutes(fastify) {
mime_type: true,
file_size: true,
file_sha256: true,
governance_status: true,
sensitivity_level: true,
attachment_type: true,
reviewed_by: true,
reviewed_at: true,
review_note: true,
archived_by: true,
archived_at: true,
archive_note: true,
download_policy: true,
created_at: true,
uploader: {
select: {
@@ -917,6 +964,7 @@ async function paymentBatchesRoutes(fastify) {
extra: {
attachmentIds: createdItems.map((item) => item.id),
attachmentCount: createdItems.length,
attachmentTypes: [...new Set(createdItems.map((item) => item.attachment_type))],
},
}),
});
@@ -940,11 +988,19 @@ async function paymentBatchesRoutes(fastify) {
storage_path: true,
original_name: true,
mime_type: true,
governance_status: true,
sensitivity_level: true,
attachment_type: true,
download_policy: true,
},
});
if (!attachment) {
throw buildNotFoundError();
}
const downloadReason = sanitizeDownloadReason(request.query?.reason);
if (requiresDownloadReason(attachment) && !downloadReason) {
throw buildValidationError({ reason: "required" });
}
let stream = null;
try {
@@ -966,7 +1022,13 @@ async function paymentBatchesRoutes(fastify) {
action: "PAYMENT_BATCH_RECEIPT_DOWNLOADED",
targetType: "payment_batch",
targetId: batchId,
extra: { attachmentId: attachment.id },
extra: {
attachmentId: attachment.id,
attachmentType: attachment.attachment_type ?? ATTACHMENT_TYPE.receipt,
sensitivityLevel: attachment.sensitivity_level ?? SENSITIVITY_LEVEL.confidential,
governanceStatus: attachment.governance_status ?? "approved",
reason: downloadReason,
},
}),
});
+86
查看文件
@@ -34,6 +34,16 @@ const {
const { NOTIFICATION_TYPES } = require("../constants/notificationTypes");
const { openReadStream, StorageNotFoundError, writeBuffer } = require("../services/storage");
const { dispatchNotificationBatch } = require("../services/notificationDispatcher");
const {
ATTACHMENT_TYPE,
DOWNLOAD_POLICY,
SENSITIVITY_LEVEL,
governanceCreateData,
mapGovernanceFields,
parseMultipartGovernanceField,
requiresDownloadReason,
sanitizeDownloadReason,
} = require("../services/businessAttachmentGovernance");
const UUID_SCHEMA = z.string().uuid();
const DATE_ONLY_SCHEMA = z.string().regex(/^\d{4}-\d{2}-\d{2}$/);
@@ -207,6 +217,17 @@ const PURCHASE_DETAIL_SELECT = {
original_name: true,
mime_type: true,
file_size: true,
file_sha256: true,
governance_status: true,
sensitivity_level: true,
attachment_type: true,
reviewed_by: true,
reviewed_at: true,
review_note: true,
archived_by: true,
archived_at: true,
archive_note: true,
download_policy: true,
uploaded_by: true,
created_at: true,
uploader: {
@@ -623,6 +644,13 @@ function mapPurchaseRequestDetail(item, request) {
originalName: attachment.original_name,
mimeType: attachment.mime_type,
fileSize: attachment.file_size,
fileSha256: attachment.file_sha256 ?? null,
...mapGovernanceFields(attachment, {
governanceStatus: "approved",
sensitivityLevel: SENSITIVITY_LEVEL.internal,
attachmentType: ATTACHMENT_TYPE.other,
downloadPolicy: DOWNLOAD_POLICY.standard,
}),
uploadedBy: attachment.uploaded_by,
createdAt: toIsoDateTime(attachment.created_at),
uploader: mapEmployeeBasic(attachment.uploader),
@@ -984,6 +1012,7 @@ async function persistAttachment({ config, purchaseRequestId, filePart }) {
originalName: filePart.filename || safeName,
mimeType: filePart.mimetype || "application/octet-stream",
fileSize,
fileSha256: crypto.createHash("sha256").update(buffer).digest("hex"),
};
}
@@ -2199,8 +2228,13 @@ async function purchaseRequestRoutes(fastify) {
}
let filePart = null;
let governanceFields = {};
try {
for await (const part of request.parts()) {
if (part.type === "field") {
governanceFields = parseMultipartGovernanceField(part, governanceFields);
continue;
}
if (part.type === "file") {
if (filePart) {
throw buildValidationError({ file: "only_single_file_supported" });
@@ -2233,13 +2267,31 @@ async function purchaseRequestRoutes(fastify) {
original_name: stored.originalName,
mime_type: stored.mimeType,
file_size: stored.fileSize,
file_sha256: stored.fileSha256,
uploaded_by: request.employee.id,
...governanceCreateData(governanceFields, {
governanceStatus: "approved",
sensitivityLevel: SENSITIVITY_LEVEL.internal,
attachmentType: ATTACHMENT_TYPE.quote,
downloadPolicy: DOWNLOAD_POLICY.standard,
}),
},
select: {
id: true,
original_name: true,
mime_type: true,
file_size: true,
file_sha256: true,
governance_status: true,
sensitivity_level: true,
attachment_type: true,
reviewed_by: true,
reviewed_at: true,
review_note: true,
archived_by: true,
archived_at: true,
archive_note: true,
download_policy: true,
uploaded_by: true,
created_at: true,
uploader: {
@@ -2262,6 +2314,8 @@ async function purchaseRequestRoutes(fastify) {
purchaseRequestId,
originalName: stored.originalName,
fileSize: stored.fileSize,
attachmentType: created.attachment_type,
sensitivityLevel: created.sensitivity_level,
},
}),
});
@@ -2275,6 +2329,13 @@ async function purchaseRequestRoutes(fastify) {
originalName: attachment.original_name,
mimeType: attachment.mime_type,
fileSize: attachment.file_size,
fileSha256: attachment.file_sha256 ?? null,
...mapGovernanceFields(attachment, {
governanceStatus: "approved",
sensitivityLevel: SENSITIVITY_LEVEL.internal,
attachmentType: ATTACHMENT_TYPE.quote,
downloadPolicy: DOWNLOAD_POLICY.standard,
}),
uploadedBy: attachment.uploaded_by,
createdAt: toIsoDateTime(attachment.created_at),
uploader: mapEmployeeBasic(attachment.uploader),
@@ -2373,6 +2434,10 @@ async function purchaseRequestRoutes(fastify) {
},
select: {
id: true,
governance_status: true,
sensitivity_level: true,
attachment_type: true,
download_policy: true,
},
});
@@ -2383,6 +2448,10 @@ async function purchaseRequestRoutes(fastify) {
statusCode: 404,
});
}
const downloadReason = sanitizeDownloadReason(request.body?.reason ?? request.query?.reason);
if (requiresDownloadReason(attachment) && !downloadReason) {
throw buildValidationError({ reason: "required" });
}
const expiresAt = Date.now() + fastify.config.signedUrlTtlSeconds * 1000;
const token = signPayload(
@@ -2390,6 +2459,7 @@ async function purchaseRequestRoutes(fastify) {
purchaseRequestId,
attachmentId,
actorId: request.employee.id,
reason: downloadReason,
exp: expiresAt,
},
fastify.config.jwtSecret,
@@ -2408,6 +2478,10 @@ async function purchaseRequestRoutes(fastify) {
extra: {
purchaseRequestId,
expiresAt,
attachmentType: attachment.attachment_type ?? ATTACHMENT_TYPE.quote,
sensitivityLevel: attachment.sensitivity_level ?? SENSITIVITY_LEVEL.internal,
governanceStatus: attachment.governance_status ?? "approved",
reason: downloadReason,
},
}),
});
@@ -2470,6 +2544,10 @@ async function purchaseRequestRoutes(fastify) {
storage_path: true,
mime_type: true,
original_name: true,
governance_status: true,
sensitivity_level: true,
attachment_type: true,
download_policy: true,
},
});
@@ -2480,6 +2558,10 @@ async function purchaseRequestRoutes(fastify) {
statusCode: 404,
});
}
const downloadReason = sanitizeDownloadReason(request.query?.reason ?? verified.payload?.reason);
if (requiresDownloadReason(attachment) && !downloadReason) {
throw buildValidationError({ reason: "required" });
}
let opened = null;
try {
@@ -2508,6 +2590,10 @@ async function purchaseRequestRoutes(fastify) {
extra: {
purchaseRequestId,
exp: verified.payload.exp,
attachmentType: attachment.attachment_type ?? ATTACHMENT_TYPE.quote,
sensitivityLevel: attachment.sensitivity_level ?? SENSITIVITY_LEVEL.internal,
governanceStatus: attachment.governance_status ?? "approved",
reason: downloadReason,
},
}),
});
+58
查看文件
@@ -31,6 +31,16 @@ const {
} = require("../services/feeControl");
const { issueDocumentNumber } = require("../services/documentSequence");
const { openReadStream, StorageNotFoundError, writeBuffer } = require("../services/storage");
const {
ATTACHMENT_TYPE,
DOWNLOAD_POLICY,
SENSITIVITY_LEVEL,
governanceCreateData,
mapGovernanceFields,
parseMultipartGovernanceField,
requiresDownloadReason,
sanitizeDownloadReason,
} = require("../services/businessAttachmentGovernance");
const {
buildApprovalWorkflowSnapshot,
getWorkflowStepsSnapshot,
@@ -289,6 +299,12 @@ function mapReimbursementAttachment(item) {
mimeType: item.mime_type,
fileSize: item.file_size,
fileSha256: item.file_sha256,
...mapGovernanceFields(item, {
governanceStatus: "approved",
sensitivityLevel: SENSITIVITY_LEVEL.internal,
attachmentType: ATTACHMENT_TYPE.supportingMaterial,
downloadPolicy: DOWNLOAD_POLICY.standard,
}),
uploadedBy: item.uploader
? {
id: item.uploader.id,
@@ -2434,8 +2450,13 @@ async function reimbursementRoutes(fastify) {
}
const files = [];
let governanceFields = {};
try {
for await (const part of request.parts()) {
if (part.type === "field") {
governanceFields = parseMultipartGovernanceField(part, governanceFields);
continue;
}
if (part.type !== "file") {
continue;
}
@@ -2505,6 +2526,12 @@ async function reimbursementRoutes(fastify) {
const datePrefix = compactDateString(fastify.config.appTimezone);
const createdItems = [];
const governanceDefaults = {
governanceStatus: "approved",
sensitivityLevel: SENSITIVITY_LEVEL.internal,
attachmentType: ATTACHMENT_TYPE.supportingMaterial,
downloadPolicy: DOWNLOAD_POLICY.standard,
};
for (const file of files) {
const fileSha256 = sha256Hex(file.buffer);
const extension = path.extname(file.originalName ?? "").toLowerCase();
@@ -2525,6 +2552,7 @@ async function reimbursementRoutes(fastify) {
file_size: file.fileSize,
file_sha256: fileSha256,
uploaded_by: request.employee.id,
...governanceCreateData(governanceFields, governanceDefaults),
},
select: {
id: true,
@@ -2532,6 +2560,16 @@ async function reimbursementRoutes(fastify) {
mime_type: true,
file_size: true,
file_sha256: true,
governance_status: true,
sensitivity_level: true,
attachment_type: true,
reviewed_by: true,
reviewed_at: true,
review_note: true,
archived_by: true,
archived_at: true,
archive_note: true,
download_policy: true,
created_at: true,
uploader: {
select: {
@@ -2545,6 +2583,7 @@ async function reimbursementRoutes(fastify) {
createdItems.push(created);
}
const uploadedGovernance = governanceCreateData(governanceFields, governanceDefaults);
await fastify.prisma.auditLog.create({
data: buildAuditLogData(request, {
actorId: request.employee.id,
@@ -2553,6 +2592,8 @@ async function reimbursementRoutes(fastify) {
targetId: reimbursementId,
extra: {
attachmentIds: createdItems.map((item) => item.id),
attachmentType: uploadedGovernance.attachment_type,
sensitivityLevel: uploadedGovernance.sensitivity_level,
},
}),
});
@@ -2657,6 +2698,10 @@ async function reimbursementRoutes(fastify) {
storage_path: true,
original_name: true,
mime_type: true,
governance_status: true,
sensitivity_level: true,
attachment_type: true,
download_policy: true,
reimbursement_form: {
select: {
owner_id: true,
@@ -2674,6 +2719,15 @@ async function reimbursementRoutes(fastify) {
}
ensureOwnOrCanViewAll(fastify, request, attachment.reimbursement_form);
const downloadReason = sanitizeDownloadReason(request.query?.reason);
if (requiresDownloadReason(attachment) && !downloadReason) {
throw new AppError({
code: "VALID_001",
message: "参数校验失败",
statusCode: 400,
details: { reason: "required" },
});
}
let stream = null;
try {
@@ -2701,6 +2755,10 @@ async function reimbursementRoutes(fastify) {
targetId: reimbursementId,
extra: {
attachmentId: attachment.id,
attachmentType: attachment.attachment_type ?? ATTACHMENT_TYPE.supportingMaterial,
sensitivityLevel: attachment.sensitivity_level ?? SENSITIVITY_LEVEL.internal,
governanceStatus: attachment.governance_status ?? "approved",
reason: downloadReason,
},
}),
});
@@ -0,0 +1,143 @@
const GOVERNANCE_STATUS = Object.freeze({
draft: "draft",
underApproval: "under_approval",
pendingReview: "pending_review",
approved: "approved",
archived: "archived",
returned: "returned",
rejected: "rejected",
quarantined: "quarantined",
});
const SENSITIVITY_LEVEL = Object.freeze({
public: "public",
internal: "internal",
confidential: "confidential",
restricted: "restricted",
indexOnly: "index_only",
});
const ATTACHMENT_TYPE = Object.freeze({
contractDraft: "contract_draft",
contractStamped: "contract_stamped",
invoice: "invoice",
receipt: "receipt",
quote: "quote",
acceptance: "acceptance",
delivery: "delivery",
policyDocument: "policy_document",
supportingMaterial: "supporting_material",
other: "other",
});
const DOWNLOAD_POLICY = Object.freeze({
standard: "standard",
reasonRequired: "reason_required",
restricted: "restricted",
});
const GOVERNANCE_STATUS_VALUES = new Set(Object.values(GOVERNANCE_STATUS));
const SENSITIVITY_LEVEL_VALUES = new Set(Object.values(SENSITIVITY_LEVEL));
const ATTACHMENT_TYPE_VALUES = new Set(Object.values(ATTACHMENT_TYPE));
const DOWNLOAD_POLICY_VALUES = new Set(Object.values(DOWNLOAD_POLICY));
const DEFAULT_GOVERNANCE = Object.freeze({
governanceStatus: GOVERNANCE_STATUS.approved,
sensitivityLevel: SENSITIVITY_LEVEL.internal,
attachmentType: ATTACHMENT_TYPE.other,
downloadPolicy: DOWNLOAD_POLICY.standard,
});
function normalizeEnumValue(value, allowedValues, fallback) {
if (typeof value !== "string") {
return fallback;
}
const normalized = value.trim();
return allowedValues.has(normalized) ? normalized : fallback;
}
function normalizeGovernanceInput(input = {}, defaults = DEFAULT_GOVERNANCE) {
return {
governanceStatus: normalizeEnumValue(input.governanceStatus ?? input.governance_status, GOVERNANCE_STATUS_VALUES, defaults.governanceStatus),
sensitivityLevel: normalizeEnumValue(input.sensitivityLevel ?? input.sensitivity_level, SENSITIVITY_LEVEL_VALUES, defaults.sensitivityLevel),
attachmentType: normalizeEnumValue(input.attachmentType ?? input.attachment_type, ATTACHMENT_TYPE_VALUES, defaults.attachmentType),
downloadPolicy: normalizeEnumValue(input.downloadPolicy ?? input.download_policy, DOWNLOAD_POLICY_VALUES, defaults.downloadPolicy),
};
}
function governanceCreateData(input = {}, defaults = DEFAULT_GOVERNANCE) {
const normalized = normalizeGovernanceInput(input, defaults);
return {
governance_status: normalized.governanceStatus,
sensitivity_level: normalized.sensitivityLevel,
attachment_type: normalized.attachmentType,
download_policy: normalized.downloadPolicy,
};
}
function mapGovernanceFields(item = {}, defaults = DEFAULT_GOVERNANCE) {
const normalized = normalizeGovernanceInput({
governance_status: item.governance_status,
sensitivity_level: item.sensitivity_level,
attachment_type: item.attachment_type,
download_policy: item.download_policy,
}, defaults);
return {
governanceStatus: normalized.governanceStatus,
sensitivityLevel: normalized.sensitivityLevel,
attachmentType: normalized.attachmentType,
downloadPolicy: normalized.downloadPolicy,
reviewedBy: item.reviewed_by ?? null,
reviewedAt: item.reviewed_at?.toISOString?.() ?? null,
reviewNote: item.review_note ?? null,
archivedBy: item.archived_by ?? null,
archivedAt: item.archived_at?.toISOString?.() ?? null,
archiveNote: item.archive_note ?? null,
};
}
function parseMultipartGovernanceField(part, current = {}) {
if (!part || part.type !== "field") {
return current;
}
const fieldName = part.fieldname;
if (!["attachmentType", "sensitivityLevel", "governanceStatus", "downloadPolicy"].includes(fieldName)) {
return current;
}
return {
...current,
[fieldName]: typeof part.value === "string" ? part.value : "",
};
}
function requiresDownloadReason(attachment = {}) {
const governance = normalizeGovernanceInput(attachment);
return (
governance.downloadPolicy === DOWNLOAD_POLICY.reasonRequired ||
governance.downloadPolicy === DOWNLOAD_POLICY.restricted ||
governance.sensitivityLevel === SENSITIVITY_LEVEL.confidential ||
governance.sensitivityLevel === SENSITIVITY_LEVEL.restricted
);
}
function sanitizeDownloadReason(reason) {
if (typeof reason !== "string") {
return null;
}
const trimmed = reason.trim();
return trimmed ? trimmed.slice(0, 200) : null;
}
module.exports = {
ATTACHMENT_TYPE,
DEFAULT_GOVERNANCE,
DOWNLOAD_POLICY,
GOVERNANCE_STATUS,
SENSITIVITY_LEVEL,
governanceCreateData,
mapGovernanceFields,
normalizeGovernanceInput,
parseMultipartGovernanceField,
requiresDownloadReason,
sanitizeDownloadReason,
};
@@ -48,10 +48,18 @@ const OWNER_ID = "77777777-7777-4777-8777-777777777777";
const SUBMITTER_ID = "88888888-8888-4888-8888-888888888888";
const RECEIPT_ID = "99999999-9999-4999-8999-999999999999";
function buildMultipartRequest({ files }) {
function buildMultipartRequest({ files, fields = {} }) {
const boundary = "----codex-payment-batch-receipt-boundary";
const segments = [];
for (const [name, value] of Object.entries(fields)) {
segments.push(
Buffer.from(
`--${boundary}\r\nContent-Disposition: form-data; name="${name}"\r\n\r\n${value}\r\n`,
),
);
}
for (const file of files) {
segments.push(
Buffer.from(
@@ -587,6 +595,10 @@ describe("payment batch reimbursement routes", () => {
expect(uploadResponse.json().data.items[0]).toMatchObject({
originalName: "bank-receipt.pdf",
mimeType: "application/pdf",
governanceStatus: "approved",
sensitivityLevel: "confidential",
attachmentType: "receipt",
downloadPolicy: "reason_required",
});
const attachmentId = uploadResponse.json().data.items[0].id;
expect(prisma.paymentBatchReceiptAttachment.create).toHaveBeenCalledTimes(1);
@@ -610,14 +622,43 @@ describe("payment batch reimbursement routes", () => {
});
expect(listResponse.statusCode).toBe(200);
expect(listResponse.json().data.items).toHaveLength(1);
expect(listResponse.json().data.items[0]).toMatchObject({
governanceStatus: "approved",
sensitivityLevel: "confidential",
attachmentType: "receipt",
downloadPolicy: "reason_required",
});
const downloadResponse = await app.inject({
const missingReasonResponse = await app.inject({
method: "GET",
url: `/api/payment-batches/${BATCH_ID}/receipt-attachments/${attachmentId}/file`,
headers: { authorization: "Bearer test" },
});
expect(missingReasonResponse.statusCode).toBe(400);
expect(missingReasonResponse.json().error.details).toEqual({ reason: "required" });
const downloadResponse = await app.inject({
method: "GET",
url: `/api/payment-batches/${BATCH_ID}/receipt-attachments/${attachmentId}/file?reason=付款复核`,
headers: { authorization: "Bearer test" },
});
expect(downloadResponse.statusCode).toBe(200);
expect(downloadResponse.body).toBe("%PDF bank receipt");
expect(prisma.auditLog.create).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.objectContaining({
action: "PAYMENT_BATCH_RECEIPT_DOWNLOADED",
target_id: BATCH_ID,
extra: expect.objectContaining({
attachmentId,
attachmentType: "receipt",
sensitivityLevel: "confidential",
governanceStatus: "approved",
reason: "付款复核",
}),
}),
}),
);
const deleteResponse = await app.inject({
method: "DELETE",
@@ -8,10 +8,18 @@ const multipart = require("@fastify/multipart");
const { AppError } = require("../../../src/utils/AppError");
const reimbursementRoutes = require("../../../src/routes/reimbursements");
function buildMultipartRequest({ files }) {
function buildMultipartRequest({ files, fields = {} }) {
const boundary = "----codex-reimbursement-attachment-boundary";
const segments = [];
for (const [name, value] of Object.entries(fields)) {
segments.push(
Buffer.from(
`--${boundary}\r\nContent-Disposition: form-data; name="${name}"\r\n\r\n${value}\r\n`,
),
);
}
for (const file of files) {
segments.push(
Buffer.from(
@@ -55,6 +63,16 @@ async function buildApp({ reimbursementStatus = "draft" } = {}) {
mime_type: data.mime_type,
file_size: data.file_size,
file_sha256: data.file_sha256,
governance_status: data.governance_status,
sensitivity_level: data.sensitivity_level,
attachment_type: data.attachment_type,
reviewed_by: data.reviewed_by ?? null,
reviewed_at: data.reviewed_at ?? null,
review_note: data.review_note ?? null,
archived_by: data.archived_by ?? null,
archived_at: data.archived_at ?? null,
archive_note: data.archive_note ?? null,
download_policy: data.download_policy,
storage_path: data.storage_path,
created_at: new Date("2026-05-08T08:00:00.000Z"),
uploader: {
@@ -91,6 +109,10 @@ async function buildApp({ reimbursementStatus = "draft" } = {}) {
storage_path: item.storage_path,
original_name: item.original_name,
mime_type: item.mime_type,
governance_status: item.governance_status,
sensitivity_level: item.sensitivity_level,
attachment_type: item.attachment_type,
download_policy: item.download_policy,
reimbursement_form: {
owner_id: "actor-1",
submitter_id: "actor-1",
@@ -201,6 +223,10 @@ describe("reimbursement attachment routes", () => {
expect.objectContaining({
originalName: "contract.pdf",
mimeType: "application/pdf",
governanceStatus: "approved",
sensitivityLevel: "internal",
attachmentType: "supporting_material",
downloadPolicy: "standard",
uploadedBy: expect.objectContaining({ name: "测试用户" }),
}),
);
@@ -210,6 +236,10 @@ describe("reimbursement attachment routes", () => {
data: expect.objectContaining({
action: "REIMBURSEMENT_ATTACHMENTS_UPLOADED",
target_id: reimbursementId,
extra: expect.objectContaining({
attachmentType: "supporting_material",
sensitivityLevel: "internal",
}),
}),
}),
);