修复附件治理部署检查缺口(05-28-attachment-governance-deploy-smoke)
这个提交包含在:
@@ -0,0 +1,5 @@
|
||||
{"file": ".trellis/spec/hyb-oa/project-governance.md", "reason": "核对生产审批边界"}
|
||||
{"file": ".trellis/spec/hyb-oa/deployment-trialrun.md", "reason": "核对部署与 smoke 门禁"}
|
||||
{"file": ".trellis/spec/hyb-oa/backend-fastify-prisma.md", "reason": "核对迁移校验"}
|
||||
{"file": ".trellis/spec/hyb-oa/security-privacy.md", "reason": "核对敏感信息边界"}
|
||||
{"file": "docs/试运行与自动化验证手册.md", "reason": "核对公网验证手册"}
|
||||
@@ -0,0 +1,65 @@
|
||||
# 附件治理部署与公网烟测设计
|
||||
|
||||
## 部署策略
|
||||
|
||||
采用现有单机试运行部署脚本,不新增部署机制:
|
||||
|
||||
```bash
|
||||
SYNC_ENV=0 bash deploy/trialrun/deploy.sh
|
||||
```
|
||||
|
||||
理由:
|
||||
|
||||
- `SYNC_ENV=0` 是项目规范要求的安全默认值,不覆盖远端 `.env`。
|
||||
- 脚本已经覆盖环境契约、rsync、远端 Docker Compose、Prisma migration、seed、`/ready` 和 runtime smoke。
|
||||
- 本轮业务附件 V1 不新增环境变量,不需要改远端配置。
|
||||
|
||||
## 迁移影响
|
||||
|
||||
本轮待部署迁移:
|
||||
|
||||
- `server/prisma/migrations/20260528162000_business_attachment_governance_v1/migration.sql`
|
||||
|
||||
迁移内容:
|
||||
|
||||
- `purchase_attachments` 新增 `file_sha256`、治理状态、敏感级别、附件类型、复核/归档和下载策略字段。
|
||||
- `reimbursement_attachments` 新增治理状态、敏感级别、附件类型、复核/归档和下载策略字段。
|
||||
- `payment_batch_receipt_attachments` 新增治理状态、敏感级别、附件类型、复核/归档和下载策略字段。
|
||||
- 新增 hash、治理状态和敏感级别索引。
|
||||
|
||||
风险判断:
|
||||
|
||||
- 新增字段均有默认值或可空,不改写历史附件文件。
|
||||
- 不删除列、不改主键、不重命名表,不涉及历史数据迁移。
|
||||
- 部署失败时优先回滚应用到上一个 commit;数据库新增列可暂留,旧代码会忽略新增字段。
|
||||
|
||||
## 执行顺序
|
||||
|
||||
1. 本地预检:
|
||||
- `git status --short`
|
||||
- `node deploy/trialrun/validate-env-contract.js`
|
||||
- `cd server && npm run prisma:validate`
|
||||
- `cd client && npm run typecheck && npm run build`
|
||||
- Trellis governance 和 `git diff --check`
|
||||
2. 用户明确批准部署。
|
||||
3. 执行:
|
||||
- `SYNC_ENV=0 bash deploy/trialrun/deploy.sh`
|
||||
4. 部署后检查:
|
||||
- 记录部署 commit:`52e5284` 或当前 HEAD。
|
||||
- 确认公网 `/ready`。
|
||||
- 跑 live smoke readiness。
|
||||
- 进入 OA 付款批次回单附件,确认下载用途弹窗。
|
||||
- 检查审计日志中出现回单下载记录和用途。
|
||||
- 查看报销/采购附件区域,确认状态、类型、敏感级别标签。
|
||||
|
||||
## 回滚口径
|
||||
|
||||
- 应用回滚:部署上一个稳定 commit,并复跑 `/ready`、runtime smoke 和 live readiness。
|
||||
- 数据库:不主动删除新增列;只有确认无新代码依赖且另行批准时,才考虑反向 DDL。
|
||||
- 数据:不清理公网业务数据,不删除真实附件。
|
||||
- 配置:本轮不改远端 `.env`,无需配置回滚。
|
||||
|
||||
## 证据口径
|
||||
|
||||
- 可报告:commit hash、命令结论、`/ready` 状态、smoke 结果、脱敏错误摘要、审计动作是否存在。
|
||||
- 不报告:密码、token、Secret、完整账号、完整路径、真实附件内容、原始 payload。
|
||||
@@ -0,0 +1,5 @@
|
||||
{"file": ".trellis/spec/hyb-oa/project-governance.md", "reason": "部署和生产操作审批边界"}
|
||||
{"file": ".trellis/spec/hyb-oa/deployment-trialrun.md", "reason": "公网部署、smoke 和回滚门禁"}
|
||||
{"file": ".trellis/spec/hyb-oa/backend-fastify-prisma.md", "reason": "Prisma migration 部署和校验规则"}
|
||||
{"file": ".trellis/spec/hyb-oa/security-privacy.md", "reason": "附件治理和敏感证据边界"}
|
||||
{"file": "docs/试运行与自动化验证手册.md", "reason": "公网 live smoke 和试运行验证手册"}
|
||||
@@ -0,0 +1,74 @@
|
||||
# 附件治理部署与公网烟测
|
||||
|
||||
## Goal
|
||||
|
||||
将已提交的“业务附件受控入库 V1”安全部署到公网 OA,并完成迁移、健康检查、公网 smoke 和关键业务验证,确认报销附件、采购附件、付款回单附件的治理字段、下载用途和审计留痕在公网可用。
|
||||
|
||||
## What I Already Know
|
||||
|
||||
- 本轮待部署工作提交为 `52e5284`,任务已归档提交为 `fef5966`。
|
||||
- 业务附件 V1 包含 Prisma migration:`20260528162000_business_attachment_governance_v1`。
|
||||
- 部署入口为 `SYNC_ENV=0 bash deploy/trialrun/deploy.sh`,默认不覆盖远端 `.env`。
|
||||
- 部署脚本会在远端执行 `npm run prisma:migrate:deploy`、`seed:config`、`seed:trialrun`、`npx prisma migrate status`、`/ready`、external deps smoke 和 runtime smoke。
|
||||
- 部署后还需执行公网 live smoke 和面向附件治理的关键路径检查。
|
||||
- 生产部署、远端配置修改、数据库迁移和公网真实数据写入都需要明确批准。
|
||||
|
||||
## Scope
|
||||
|
||||
- 部署前本地静态门禁:部署环境契约、Prisma validate、前端 typecheck/build、Trellis governance。
|
||||
- 部署前确认迁移影响和回滚口径:新增列/索引,不重置数据库、不改写历史迁移、不覆盖远端 `.env`。
|
||||
- 获得明确批准后执行公网部署。
|
||||
- 部署后检查:
|
||||
- `/ready`
|
||||
- `npx prisma migrate status`
|
||||
- external deps smoke
|
||||
- runtime smoke
|
||||
- live smoke readiness
|
||||
- 公网前端资源文案扫描
|
||||
- 付款回单下载用途弹窗和审计日志
|
||||
- 报销/采购附件治理标签展示
|
||||
|
||||
## Out Of Scope
|
||||
|
||||
- 不迁移历史附件文件,不接 COS,不改存储驱动。
|
||||
- 不覆盖远端 `.env`,除非另行明确批准 `SYNC_ENV=1`。
|
||||
- 不执行真实付款、不导入银行流水、不写 ERP、不写企业微信。
|
||||
- 不使用真实敏感票据、合同正文、银行材料或完整账号做自动化证据。
|
||||
- 不做数据库 reset、迁移漂移修复或迁移历史重写。
|
||||
|
||||
## Requirements
|
||||
|
||||
- R1:部署前必须确认本地工作区干净,当前 commit 可追溯。
|
||||
- R2:部署前必须跑本地静态门禁和 Trellis governance,失败则不部署。
|
||||
- R3:部署必须使用 `SYNC_ENV=0`,保持远端 `.env` 原状。
|
||||
- R4:部署脚本执行远端迁移后必须确认 migrate status 通过。
|
||||
- R5:公网 smoke 失败时,停止并保留错误摘要,不继续宣称可以测试。
|
||||
- R6:附件治理专项验收必须覆盖付款回单下载用途、审计日志、报销/采购附件治理标签。
|
||||
- R7:报告不得输出 Secret、密码、完整账号、完整本地路径、附件原文或原始 payload。
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
- [x] 部署前静态门禁通过。
|
||||
- [ ] 获得明确“批准部署”后执行公网部署。
|
||||
- [ ] 部署脚本完成,远端 `/ready` 通过。
|
||||
- [ ] 远端 Prisma migration status 通过。
|
||||
- [ ] 公网 live smoke 或 readiness 按规则输出 PASS/SKIP/FINDINGS,结论清晰。
|
||||
- [ ] 业务附件专项验证通过或记录阻断项。
|
||||
- [x] Trellis task validate、governance gate、`git diff --check` 通过。
|
||||
|
||||
## Test Plan
|
||||
|
||||
- `node deploy/trialrun/validate-env-contract.js`
|
||||
- `cd server && npm run prisma:validate`
|
||||
- `cd client && npm run typecheck && npm run build`
|
||||
- `python3 ./.trellis/scripts/task.py validate 05-28-attachment-governance-deploy-smoke`
|
||||
- `node scripts/qa/check-trellis-governance.mjs`
|
||||
- `git diff --check`
|
||||
- 获批后:`SYNC_ENV=0 bash deploy/trialrun/deploy.sh`
|
||||
- 部署后:`LIVE_SMOKE_BASE_URL=https://oa.hyinda.com bash scripts/qa/run-live-smoke-readiness.sh`
|
||||
|
||||
## Technical Notes
|
||||
|
||||
- 参考规范:`.trellis/spec/hyb-oa/deployment-trialrun.md`、`.trellis/spec/hyb-oa/backend-fastify-prisma.md`、`.trellis/spec/hyb-oa/security-privacy.md`。
|
||||
- 参考手册:`docs/试运行与自动化验证手册.md`。
|
||||
- 本任务当前阶段只做部署前准备;生产部署必须等用户明确回复“批准部署”。
|
||||
@@ -0,0 +1,32 @@
|
||||
{
|
||||
"id": "attachment-governance-deploy-smoke",
|
||||
"name": "attachment-governance-deploy-smoke",
|
||||
"title": "附件治理部署与公网烟测",
|
||||
"description": "部署业务附件治理 V1 到公网 OA,并完成迁移、健康检查、live smoke 和附件治理专项验证。",
|
||||
"status": "in_progress",
|
||||
"dev_type": null,
|
||||
"scope": "deployment-smoke",
|
||||
"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": null,
|
||||
"relatedFiles": [
|
||||
".trellis/tasks/05-28-attachment-governance-deploy-smoke/prd.md",
|
||||
".trellis/tasks/05-28-attachment-governance-deploy-smoke/design.md",
|
||||
".trellis/tasks/archive/2026-05/05-28-business-attachment-governance-v1/prd.md",
|
||||
"deploy/trialrun/deploy.sh",
|
||||
"docs/试运行与自动化验证手册.md"
|
||||
],
|
||||
"notes": "",
|
||||
"meta": {}
|
||||
}
|
||||
@@ -52,6 +52,10 @@ type ReceiptAttachment = {
|
||||
originalName: string;
|
||||
mimeType: string;
|
||||
fileSize: number;
|
||||
governanceStatus: string;
|
||||
sensitivityLevel: string;
|
||||
attachmentType: string;
|
||||
downloadPolicy: string;
|
||||
};
|
||||
|
||||
type FinanceLedgerResponse = {
|
||||
@@ -500,8 +504,18 @@ test.describe("公网付款批次受控验收", () => {
|
||||
finance.api,
|
||||
`/api/payment-batches/${batch.id}/receipt-attachments`,
|
||||
);
|
||||
expect(receiptList.items.some((item) => item.id === receipt.id)).toBe(true);
|
||||
const receiptDownload = await finance.api.get(`/api/payment-batches/${batch.id}/receipt-attachments/${receipt.id}/file`);
|
||||
const listedReceipt = receiptList.items.find((item) => item.id === receipt.id);
|
||||
expect(listedReceipt).toBeTruthy();
|
||||
expect(listedReceipt?.governanceStatus).toBe("approved");
|
||||
expect(listedReceipt?.sensitivityLevel).toBe("confidential");
|
||||
expect(listedReceipt?.attachmentType).toBe("receipt");
|
||||
expect(listedReceipt?.downloadPolicy).toBe("reason_required");
|
||||
const receiptDownloadWithoutReason = await finance.api.get(`/api/payment-batches/${batch.id}/receipt-attachments/${receipt.id}/file`);
|
||||
expect(receiptDownloadWithoutReason.status(), "bank receipt download without reason should be rejected").toBe(400);
|
||||
const receiptDownloadReason = `${issueId} live payment receipt verification`;
|
||||
const receiptDownload = await finance.api.get(
|
||||
`/api/payment-batches/${batch.id}/receipt-attachments/${receipt.id}/file?reason=${encodeURIComponent(receiptDownloadReason)}`,
|
||||
);
|
||||
expect(receiptDownload.status(), "bank receipt download should succeed").toBe(200);
|
||||
|
||||
batch = (await apiPostData<{ item: PaymentBatch }>(finance.api, `/api/payment-batches/${batch.id}/execute`, {})).item;
|
||||
@@ -533,6 +547,14 @@ test.describe("公网付款批次受控验收", () => {
|
||||
await expectAuditAction(admin.api, "PAYMENT_BATCH_RECEIPT_UPLOADED", (item) =>
|
||||
item.targetId === batch.id && Array.isArray(item.extra?.attachmentIds),
|
||||
);
|
||||
await expectAuditAction(admin.api, "PAYMENT_BATCH_RECEIPT_DOWNLOADED", (item) =>
|
||||
item.targetId === batch.id &&
|
||||
item.extra?.attachmentId === receipt.id &&
|
||||
item.extra?.attachmentType === "receipt" &&
|
||||
item.extra?.sensitivityLevel === "confidential" &&
|
||||
item.extra?.governanceStatus === "approved" &&
|
||||
item.extra?.reason === receiptDownloadReason,
|
||||
);
|
||||
await expectAuditAction(admin.api, "PAYMENT_BATCH_EXECUTED", (item) => item.targetId === batch.id);
|
||||
await expectAuditAction(admin.api, "REIMBURSEMENT_PAYMENT_MARKED", (item) => item.targetId === reimbursement.id);
|
||||
await expectAuditAction(admin.api, "EXPORT_AP_OPERATIONS", (item) => item.actorId === finance.me.id);
|
||||
@@ -542,7 +564,14 @@ test.describe("公网付款批次受控验收", () => {
|
||||
batchStatus: batch.status,
|
||||
receiptAttachmentId: receipt.id,
|
||||
missingReceiptExecuteStatus,
|
||||
receiptDownloadWithoutReasonStatus: receiptDownloadWithoutReason.status(),
|
||||
receiptDownloadStatus: receiptDownload.status(),
|
||||
receiptGovernance: {
|
||||
governanceStatus: listedReceipt?.governanceStatus,
|
||||
sensitivityLevel: listedReceipt?.sensitivityLevel,
|
||||
attachmentType: listedReceipt?.attachmentType,
|
||||
downloadPolicy: listedReceipt?.downloadPolicy,
|
||||
},
|
||||
reimbursementStatus: paid.status,
|
||||
invoiceStatus: invoice.status,
|
||||
ledgerPaymentStatus: ledgerItem?.payment.status ?? "missing",
|
||||
|
||||
@@ -2061,6 +2061,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: {
|
||||
|
||||
在新工单中引用
屏蔽一个用户