实现企微原生材料预览(07-27-yang-jun-reimbursement-readiness)
这个提交包含在:
@@ -510,6 +510,11 @@ const TRAVEL_TRANSPORT_EXPENSE_TYPE_CODES = { bus: "travel_bus" };
|
||||
- Reimbursement attachments and invoice PDFs retain `inline`; purchase,
|
||||
employee and payment receipt downloads keep their existing default
|
||||
`attachment` behavior through the same shared builder.
|
||||
- Inside the WeCom self-built app, reimbursement invoice and attachment viewing
|
||||
uses the official `previewFile` JSAPI with accurate persisted file name/size.
|
||||
Because the native layer does not reliably carry OA authentication headers,
|
||||
only the narrow grant defined in `wecom-boundary.md` may use a query token;
|
||||
expiry is at most 120 seconds and ordinary PC downloads remain unchanged.
|
||||
|
||||
### 4. Validation & Error Matrix
|
||||
|
||||
@@ -519,6 +524,8 @@ const TRAVEL_TRANSPORT_EXPENSE_TYPE_CODES = { bus: "travel_bus" };
|
||||
- Unsupported MIME, empty file or oversized file -> existing stable file
|
||||
validation error; no partial persistence.
|
||||
- Chinese name -> `inline; filename="attachment.<ext>"; filename*=UTF-8''...`.
|
||||
- WeCom preview grant is expired, tampered or bound to another reimbursement or
|
||||
attachment -> `403` with no file bytes.
|
||||
- Invalid disposition value -> `TypeError`; callers may use only `inline` or
|
||||
`attachment`.
|
||||
|
||||
@@ -539,6 +546,10 @@ const TRAVEL_TRANSPORT_EXPENSE_TYPE_CODES = { bus: "travel_bus" };
|
||||
- Download an existing Chinese-name reimbursement attachment and invoice PDF;
|
||||
assert `inline`, ASCII `filename`, percent-encoded `filename*`, and no raw
|
||||
Chinese in the header.
|
||||
- Assert the WeCom grant carries the exact original name and file size, expires
|
||||
within 120 seconds, records issue/access audits and rejects tamper, expiry and
|
||||
object mismatch; Playwright verifies PC browser behavior and WeCom
|
||||
`previewFile` behavior with synthetic files.
|
||||
- Cover CR/LF, quote and path cleanup in the shared Content-Disposition helper,
|
||||
while retaining existing purchase `attachment` regression tests.
|
||||
- Run backend full tests and frontend typecheck/build because the limit and
|
||||
|
||||
@@ -134,7 +134,10 @@
|
||||
`buffer`, `extension`, `mimeType`, `fileSize` and `fileSha256`.
|
||||
- Browser downloads of OA-stored attachments must use a currently
|
||||
authenticated request, preferably an API-local `POST` with optional JSON
|
||||
`{ "reason": "..." }`, rather than a query bearer URL.
|
||||
`{ "reason": "..." }`, rather than a query bearer URL. The only approved
|
||||
exception is the at-most-120-second, purpose/object/actor-bound grant used by
|
||||
the official WeCom `previewFile` native layer; follow `wecom-boundary.md` and
|
||||
do not reuse that grant for ordinary browser downloads or other integrations.
|
||||
|
||||
### 3. Contracts
|
||||
|
||||
@@ -163,9 +166,12 @@
|
||||
- Successful download audit is part of authorization: write it before setting
|
||||
attachment headers or sending bytes. Audit failure must return a non-success
|
||||
response with no file body.
|
||||
- Successful responses use `application/octet-stream`, `attachment`,
|
||||
`nosniff`, private no-store caching, no-referrer and a route-level sandbox
|
||||
CSP. Do not restore same-origin inline preview or query-token download routes.
|
||||
- Successful ordinary browser downloads use `application/octet-stream`,
|
||||
`attachment`, `nosniff`, private no-store caching, no-referrer and a
|
||||
route-level sandbox CSP. Do not restore general same-origin inline preview or
|
||||
query-token download routes. The WeCom-native reimbursement invoice/attachment
|
||||
exception retains the domain-specific `inline` response and must still audit
|
||||
before bytes, use no-store/no-referrer and reject expired or mismatched grants.
|
||||
|
||||
### 4. Validation & Error Matrix
|
||||
|
||||
@@ -220,10 +226,11 @@
|
||||
- Frontend typecheck/build after API type changes.
|
||||
- Focused UI verification for pages that add attachment governance labels or
|
||||
download-reason interactions.
|
||||
- Frontend download tests must assert authenticated JSON request + Blob browser
|
||||
download and absence of popup/inline preview. Use only synthetic in-memory
|
||||
fixtures; production attachments and historical bulk scanning require a
|
||||
separate approval.
|
||||
- Frontend ordinary-download tests must assert authenticated JSON request + Blob
|
||||
browser download. WeCom-native preview tests instead assert the official
|
||||
bridge receives the exact short URL/name/size and that `window.open` is not
|
||||
used. Use only synthetic in-memory fixtures; production attachments and
|
||||
historical bulk scanning require a separate approval.
|
||||
|
||||
### 7. Wrong vs Correct
|
||||
|
||||
|
||||
@@ -41,6 +41,98 @@ or administration workflow has already migrated.
|
||||
rule, applicant-visible mobile submission page and actual post-submission
|
||||
approval chain; API template metadata alone is insufficient.
|
||||
|
||||
## Scenario: Native File Preview With Narrow Short-Lived Grants
|
||||
|
||||
### 1. Scope / Trigger
|
||||
|
||||
- Trigger: an authenticated OA page inside the Enterprise WeChat self-built app
|
||||
must preview a reimbursement invoice or supporting file with the official
|
||||
`previewFile` JSAPI.
|
||||
- Purpose: WeCom's native downloader does not reliably carry the WebView cookie
|
||||
or OA bearer header, while ordinary PDF links may download instead of preview.
|
||||
This is a narrow native-preview bridge, not a general query-token download
|
||||
design.
|
||||
|
||||
### 2. Signatures
|
||||
|
||||
- JS-SDK signature: `POST /api/auth/wecom/js-sdk-signature`, authenticated body
|
||||
`{ url: string }`; response `{ corpId, timestamp, nonceStr, signature }`.
|
||||
- Invoice grant: `POST /api/invoice-pool/:id/signed-url`; response includes
|
||||
`{ signedUrl, expiresAt, originalName, mimeType, fileSize }`.
|
||||
- Reimbursement attachment grant:
|
||||
`POST /api/reimbursements/:id/attachments/:attachmentId/preview-url`, optional
|
||||
body `{ reason }`; response uses the same file metadata shape.
|
||||
- Native call: `previewFile({ url, name, size })`; `name` includes an extension
|
||||
and `size` is the persisted positive byte count.
|
||||
|
||||
### 3. Contracts
|
||||
|
||||
- Fetch and cache the WeCom JSAPI ticket only on the server. Never return or log
|
||||
the ticket, app access token, Agent Secret or raw provider response.
|
||||
- Sign only the first configured OA `APP_ORIGIN`; reject cross-origin URLs,
|
||||
URL credentials and fragments. The signed page URL excludes the hash.
|
||||
- A native file grant expires in at most 120 seconds and binds purpose, exact
|
||||
business object, exact file object and actor. It is signed only after current
|
||||
authentication, business-read permission and download-reason checks pass.
|
||||
- Invoice access may reuse the existing current-pending-task exception. Do not
|
||||
grant the approver invoice-management, edit, unbind or delete permission.
|
||||
- The native GET writes a successful access audit before sending headers or
|
||||
bytes and returns `private, no-store` plus `no-referrer`.
|
||||
- PC keeps its browser/authenticated download behavior. Only a detected WeCom
|
||||
WebView dynamically loads `@wecom/jssdk` and calls `previewFile`; do not send
|
||||
the actual file through a WeCom message or native approval payload.
|
||||
|
||||
### 4. Validation & Error Matrix
|
||||
|
||||
- Missing authentication on signature/grant issue -> `401`.
|
||||
- Cross-origin page URL -> `403`; malformed, credential-bearing or fragment URL
|
||||
-> `400`, before any provider call.
|
||||
- Ticket/token provider failure -> stable `503` detail without provider values.
|
||||
- Missing file metadata, extension or positive integer size -> frontend stops
|
||||
before `previewFile` and shows a stable error.
|
||||
- Missing required download reason -> `400 { reason: "required" }`.
|
||||
- Expired, tampered, wrong-purpose or object-mismatched file grant -> `403`.
|
||||
- Access audit failure -> `5xx` with no file body.
|
||||
|
||||
### 5. Good/Base/Bad Cases
|
||||
|
||||
- Good: a pending reimbursement approver opens a 120-second invoice URL through
|
||||
`previewFile`, sees the original in WeCom and receives no edit capability.
|
||||
- Base: a PC user opens the same invoice through the browser and downloads an
|
||||
attachment through the existing authenticated Blob path.
|
||||
- Bad: expose a long-lived generic `?token=`, trust a caller-supplied size/name,
|
||||
put a provider ticket in the response, or use async `window.open(blob:)` in
|
||||
WeCom and assume desktop popup behavior applies.
|
||||
|
||||
### 6. Tests Required
|
||||
|
||||
- Route tests cover same-origin signing, provider failure/redaction and rejection
|
||||
of cross-origin, credential-bearing and fragment URLs before provider access.
|
||||
- Grant tests assert exact object/actor binding, at-most-120-second expiry,
|
||||
metadata, reason gate, issue audit, access audit, tamper/expiry/mismatch denial
|
||||
and audit-failure fail-closed behavior using synthetic files only.
|
||||
- Playwright injects a synthetic `WeixinJSBridge`, asserts `previewFile` receives
|
||||
the exact URL/name/size for invoice and attachment, and asserts no
|
||||
`window.open` call in WeCom. PC assertions keep browser URL and Blob behavior.
|
||||
- Production deployment and real-device preview remain separate approvals.
|
||||
|
||||
### 7. Wrong vs Correct
|
||||
|
||||
#### Wrong
|
||||
|
||||
```text
|
||||
await downloadWithBearer();
|
||||
window.open(URL.createObjectURL(blob)); // WeCom may block after await
|
||||
```
|
||||
|
||||
#### Correct
|
||||
|
||||
```text
|
||||
authenticated grant issue -> <=120s object-bound URL
|
||||
-> ww.previewFile({ url, nameWithExtension, exactSize })
|
||||
-> audit before file bytes
|
||||
```
|
||||
|
||||
## Scenario: WeCom Member Data Is A Read-Only Source For OA
|
||||
|
||||
### 1. Scope / Trigger
|
||||
|
||||
@@ -174,3 +174,31 @@
|
||||
- 用户点击刷新后复用 `clearPwaRuntimeCaches()` 并重载当前 URL;同一次页面生命周期只提示一次,避免页面多次前后台切换反复提示。
|
||||
- 护栏只能保护已经加载过本次新代码的页面,不能反向唤醒当前仍运行 `d2841b1` 之前旧包的 WebView;首次部署本护栏时仍需主动通知马建勇手工刷新,后续部署才由版本比对接管。
|
||||
- 不新增后端 API、数据库字段、定时轮询、更新中心或强制升级状态;登录/改密页面继续沿用既有用户确认刷新逻辑。
|
||||
|
||||
## 9. 企业微信原生材料预览
|
||||
|
||||
### 9.1 运行时分流
|
||||
|
||||
- 企业微信 iOS/Android WebView:使用腾讯维护的 `@wecom/jssdk` 调用 `previewFile({ url, name, size })`,由企业微信原生层预览文件。
|
||||
- 普通 PC 浏览器:发票使用现有短时签名 URL,附件继续使用认证下载 Blob 后由浏览器打开;两端复用同一个“查看原件”业务动作,但不强求相同底层机制。
|
||||
- 非企微移动浏览器或 JS-SDK 调用失败:给出自然中文错误并保留下载/PC 查看退路;不静默宣称已预览。
|
||||
|
||||
### 9.2 JS-SDK 签名链
|
||||
|
||||
- 新增已认证的自建应用 JS-SDK 签名接口,输入为当前页面去掉 fragment 的完整 URL。
|
||||
- 服务端只允许 `APP_ORIGIN` 或当前受信请求 origin,拒绝跨 origin、带凭据 URL、非 HTTP(S) 和 fragment;复用企微应用 access token,在服务端获取并缓存 JSAPI ticket。
|
||||
- 返回仅包含 `corpId`、`timestamp`、`nonceStr`、`signature`;ticket、access token、Agent Secret 和企微原始响应不得返回或记录。
|
||||
- 客户端仅在 `isWecomWebView()` 为真时按需注册 SDK,`jsApiList` 只包含 `previewFile`,避免把 JS-SDK 加到登录页或普通浏览器主路径。
|
||||
|
||||
### 9.3 文件 URL 与授权
|
||||
|
||||
- 发票复用 `/invoice-pool/:id/signed-url`,响应补准确 `fileSize`、`originalName` 和 `mimeType`;签发沿用票据所有者/报销参与人/当前待审批人对象级授权。
|
||||
- 报销附件新增同形短时签名 URL:签发前复用 `ensureOwnOrCanViewAll`,执行治理状态与下载理由门禁;token 绑定 attachment、reimbursement、actor 和用途,TTL 取现有签名 TTL 与 120 秒中的较小值。
|
||||
- 原生层取件时重新校验 token、对象关系和过期时间,实际打开前写下载/预览审计。短时 URL 是企业微信原生层无法携带 OA 认证头时的窄例外,不扩展为通用 query-token 下载机制。
|
||||
- 不改角色或权限矩阵。审批人获得的只是当前报销材料只读能力,不能编辑、解绑、删除或管理票据池。
|
||||
|
||||
### 9.4 安全与兼容
|
||||
|
||||
- `name` 使用数据库原始显示名但必须确保与实际 MIME 匹配并带安全扩展名;`size` 来自持久化的准确字节数。
|
||||
- 短时 URL 不写入 Trellis、聊天、前端持久化存储、错误详情或普通日志;页面关闭/刷新后重新签发。
|
||||
- 生产首次验收先用无业务数据 PDF 证明 `config + previewFile`,再查看真实材料。部署、企微真机调用和真实审批继续单独获批。
|
||||
|
||||
@@ -107,6 +107,15 @@
|
||||
- [x] 用合成 `/ready` 响应覆盖 mismatch、match 和 failure,运行 typecheck、build、聚焦 Playwright 与 Trellis 门禁。
|
||||
- [x] 只提交并推送;生产部署和经理二次刷新另行执行,不在本步骤自动部署。
|
||||
|
||||
## 6.5 企业微信原生发票与附件预览
|
||||
|
||||
- [x] 增加企微 JSAPI ticket 获取/缓存和本站 URL 签名接口;覆盖缺配置、上游失败、跨 origin、fragment/凭据 URL 拒绝与脱敏返回。
|
||||
- [x] 发票短时 URL 响应补准确文件元数据;移动企微用 `previewFile`,PC 用浏览器预览。
|
||||
- [x] 为报销附件增加严格对象授权、理由门禁和实际取件审计的短时预览 URL;不扩大角色或通用下载权限。
|
||||
- [x] 抽取 PC/移动共用材料预览调用,企微路径不使用异步 `window.open(blob:)`;只读审批人看得到查看动作但看不到编辑、解绑、删除动作。
|
||||
- [x] 用合成文件与 mock JS bridge 运行后端聚焦测试、前端 typecheck/build 和 PC/移动 Playwright 一致性回归。
|
||||
- [x] 运行 Trellis 治理、流程/文档、敏感扫描、`git diff --check`,并对新增短时授权边界做只读 review;不在本步骤部署生产。
|
||||
|
||||
## 7. 完成条件
|
||||
|
||||
- [ ] PRD 全部验收项有脱敏证据或明确由用户完成的真实业务验收结果。
|
||||
@@ -151,3 +160,8 @@
|
||||
- 企微旧包诊断:真机截图中的报销日期与已部署按钮属于同一个无额外条件的 JSX 块;公网 `/ready` 为部署构建且当前移动 chunk 包含入口文案,因此排除权限、报销关联缺失和未进入详情,结论为存活 WebView 旧包。
|
||||
- 旧包刷新护栏:页面回到前台时用 `/ready` 与前端构建标识做一次 no-store 比对;不一致只提示用户刷新,点击后清理 PWA 缓存并重载,同一生命周期不重复提示;一致、unknown/development、异常响应和网络失败静默。
|
||||
- 刷新护栏回归:mismatch/match/failure 聚焦 Playwright 3/3 通过,完整 login/portal UX 15/15 通过,typecheck/build 通过;375px 竖屏与 667×375 横屏无水平溢出,刷新按钮高度 44px。手机专项首次因测试在 React 壳监听器挂载前人工触发事件而失败,等待移动工作台渲染后复跑通过;未改生产或真实业务数据。
|
||||
- 原生预览调研:官方 `previewFile` JSAPI 与腾讯维护的 `@wecom/jssdk` 均确认支持企微 iOS/Android;普通 PDF 链接“只下载”不等于原生 JSAPI 不支持预览。原生调用必须提供可直接取件的短时 URL、带扩展名文件名和准确字节数。
|
||||
- 原生预览实现:服务端新增同源 JS-SDK 签名、发票元数据和附件 120 秒以内对象授权;客户端只在企微 WebView 动态加载 SDK。PC 发票使用短时 URL,PC 附件保留认证 Blob;移动发票/附件统一显示“查看原件”。未修改角色矩阵、workflow、数据库 schema 或真实业务数据。
|
||||
- 短时授权只读 review:grant 绑定用途、报销、附件、actor 与过期时间;签发前检查当前对象权限和下载理由,实际取件审计失败时不发送文件。review 发现附件 GET 初版会在普通未认证请求前查询对象,已改为无 grant 先认证、有 grant 先验签再查对象,并用回归固定 401/403 前置拒绝。
|
||||
- 原生预览回归:最终后端聚焦 3 个文件、46 项通过;服务端全量基线 169 个文件、1101 项通过;Prisma validate、touched server `node --check`、前端 typecheck/build 通过。聚焦 Playwright PC/企微 2/2 通过,mock bridge 收到准确 URL/name/size,企微路径未调用 `window.open`。
|
||||
- 原生预览治理:task validate、Trellis governance、process contract、敏感扫描与 `git diff --check` 通过;文档审计 0 error、1 个既有注册汇总 warning。稳定例外已写入 `wecom-boundary`、`security-privacy` 与 `reimbursement-invoice-ocr` spec。本步骤未部署生产,真机和真实材料验收仍待单独批准。
|
||||
|
||||
@@ -40,6 +40,8 @@
|
||||
- 报销详情当前把后端 ISO UTC 时间直接截取为字符串,附件和审批记录会在中国时区显示早 8 小时;报销链路的日期时间必须复用共享格式化能力并固定按 `Asia/Shanghai` 展示,不修改数据库时间或 API ISO 合同。
|
||||
- PC 与移动报销详情应按 KISS 原则收敛为“概况 → 明细 → 发票 → 附件 → 审批记录”的业务主线:删除重复状态、重复标题和面向只读用户的操作教学;没有实际内容或动作的辅助区块不渲染,同时保留编辑态必要控件、风险提示和审计信息。
|
||||
- 生产真机验收确认企微 WebView 可继续运行部署前的旧前端包:公网资源已包含“查看完整报销单”,但存活页面仍只显示报销日期。页面从后台恢复到前台时必须用现有前后端构建标识做一次轻量比对;不一致时只显示用户可控的“发现新版本,点击刷新”,点击后清理旧 PWA 缓存并重载,不自动打断当前页面操作,不扩展为完整更新管理系统。
|
||||
- 真实审批继续暴露材料查看阻断:PC 能打开普通附件,但 PC 与移动报销详情均没有发票原件入口,企微 WebView 中异步 `window.open(blob:)` 只下载附件而不能预览。企业微信自建应用内必须优先使用官方 `ww.previewFile` / `wx.previewFile` 原生能力查看 PDF;普通 PC 浏览器保留浏览器预览,两端保持“查看原件”的业务能力一致。
|
||||
- 企业微信原生预览使用准确的文件字节数、带扩展名的文件名和短时签名 URL。原生层不会携带 OA Bearer token 或可靠复用 WebView Cookie,因此附件可以在当前任务内增加仅用于 `previewFile` 的短时 bearer URL 例外;该 URL 必须在签发前执行当前对象级授权和下载理由检查,绑定文件与访问人、短时过期、不得包含长期凭据,并在实际取件时写审计。
|
||||
|
||||
## Scope
|
||||
|
||||
@@ -64,12 +66,16 @@
|
||||
- [x] 生产已显式关闭 mock OCR;`off` 路径已用测试账号复验,空发票元数据仍可绑定、提交、主管退回和清理,真实使用暂由财务查看 PDF 原件并人工核对。
|
||||
- [x] 本轮没有调用企微成员写接口;企微成员资料未被 OA 反向修改。
|
||||
- [x] 生产审批规则已收敛为唯一全金额规则,499.99、500、5000 三个边界均解析为 `department_manager → finance`,且没有 CEO 重复节点或 `finance_mgr` 空节点。
|
||||
- [ ] 移动审批任务详情中的报销摘要区提供明确的“查看完整报销单”入口,进入现有移动报销详情并能通过既有 `state.from` 返回原审批任务。
|
||||
- [ ] PC 审批任务详情提供对应的“查看报销单”入口;两端均不修改待办列表卡片的既有点击行为,不修改审批状态机、权限矩阵或后端数据合同。
|
||||
- [x] 移动审批任务详情中的报销摘要区提供明确的“查看完整报销单”入口,进入现有移动报销详情并能通过既有 `state.from` 返回原审批任务。
|
||||
- [x] PC 审批任务详情提供对应的“查看报销单”入口;两端均不修改待办列表卡片的既有点击行为,不修改审批状态机、权限矩阵或后端数据合同。
|
||||
- [x] PC、移动报销详情和 PC 审批任务详情中的 ISO 时间按 `Asia/Shanghai` 正确展示,UTC 样例不再直接显示原始小时;数据库与 API 时间合同不变。
|
||||
- [x] PC 报销详情合并重复概况,附件区不再出现“附件材料/补充附件”双标题;无关联、无预算/风险内容时不显示空辅助区块。
|
||||
- [x] 移动报销详情顶部不重复状态和教学文案;无实际操作时不显示仅含“当前状态不可编辑”的操作卡,编辑态上传与提交能力保持可用。
|
||||
- [x] 已登录页面从后台恢复到前台且前后端构建不一致时显示明确刷新提示;构建一致、检查失败或开发构建时不误提示,刷新动作复用缓存清理后重载能力。
|
||||
- [x] 移动企微报销详情中的发票和 PDF/图片附件均提供“查看原件”,通过官方 JS-SDK 原生预览打开;调用参数包含准确 `size` 和带扩展名的 `name`,不依赖异步弹窗或 WebView 原生 PDF 渲染。
|
||||
- [x] PC 报销详情中的发票和附件提供对应的“查看原件”,使用短时签名 URL 或现有认证下载能力;两端审批人均只能查看当前报销绑定材料,不能编辑、解绑、删除或获得票据池管理权限。
|
||||
- [x] JS-SDK 签名接口只接受本站允许 origin 的无 fragment 页面 URL,不向客户端、日志或审计暴露 JSAPI ticket、企微 Secret 或 access token;签名失败和非企微环境安全降级。
|
||||
- [ ] 发票/附件预览 URL 的过期、篡改、对象不匹配、缺少下载理由和未授权访问均被拒绝;实际文件响应保留审计,真实材料可见前不允许完成主管审批。
|
||||
|
||||
## Out of Scope
|
||||
|
||||
@@ -91,3 +97,4 @@
|
||||
- 前端运行 `cd client && npm run typecheck && npm run build`,并用聚焦 Playwright 覆盖:移动审批任务详情进入完整报销单、返回原任务后审批动作仍可见,以及 PC 审批任务详情进入报销单。
|
||||
- 用合成 UTC 时间在聚焦 Playwright 中验证附件时间转换为上海时间,并验证 PC/移动 KISS 信息层级、编辑态关键入口和无动作只读态;不读取生产真实报销内容。
|
||||
- 用合成 `/ready` 响应验证前台恢复时的构建不一致提示、同版本静默和检查失败静默;不依赖生产真机数据。
|
||||
- 用合成企微 ticket、签名 URL 和 PDF/图片元数据验证:JS-SDK URL 签名、origin allowlist、原生 `previewFile` 参数、PC fallback、审批人对象级只读授权、附件理由门禁、短时 token 过期/篡改拒绝和实际查看审计;不得使用真实发票或附件。
|
||||
|
||||
+40
@@ -0,0 +1,40 @@
|
||||
# 企业微信自建应用原生文件预览调研
|
||||
|
||||
## 结论
|
||||
|
||||
企业微信自建应用内可以预览 PDF,不应以普通链接在 WebView 中的下载表现判断企业微信是否具备 PDF 预览能力。官方 JS-SDK 提供 `ww.previewFile` / `wx.previewFile`,兼容企业微信 iOS、Android,由企业微信原生层下载 URL 对应文件并在内置预览器中打开,官方说明支持图片、音频、视频、文档等格式。
|
||||
|
||||
本任务应优先验证并接入企业微信原生 `previewFile`,而不是先引入 PDF.js 或服务端转图片。PC 普通浏览器继续使用浏览器预览;两端保持“查看原件”业务能力一致,但底层实现按运行环境分流。
|
||||
|
||||
## 官方证据
|
||||
|
||||
- 企业微信开发者中心《预览文件》:<https://developer.work.weixin.qq.com/document/path/100623>
|
||||
- 参数为 `url`、`size`、`name`。
|
||||
- `size` 必填且必须准确,否则打开失败。
|
||||
- `name` 应包含正确文件扩展名。
|
||||
- 原生层会重新请求文件 URL;iOS WKWebView 中的 Cookie 不会自动带给原生请求。
|
||||
- 官方建议资源 cookie-free;需要鉴权时,在 URL 中携带业务侧短时 access token。
|
||||
- 腾讯企业微信维护的 JS-SDK:<https://github.com/TencentWecom/wecom-jssdk>
|
||||
- npm 包 `@wecom/jssdk` 的类型定义明确标注 `previewFile` 兼容 WeCom iOS、Android。
|
||||
- SDK 将 `previewFile` 调用传递给企业微信原生 `WeixinJSBridge`。
|
||||
- JS-SDK 总入口:<https://developer.work.weixin.qq.com/document/path/90547>
|
||||
- 自建应用需完成 JS-SDK 注册和当前页面 URL 的 `config` 签名;官方 SDK README 说明自建应用只需提供 `getConfigSignature`。
|
||||
|
||||
## 对 hyb-oa 的影响
|
||||
|
||||
1. 当前项目没有 JS-SDK ticket/signature 接口,也未引入 `@wecom/jssdk`,需要补最小自建应用签名链路。
|
||||
2. 发票已有短时签名文件 URL,可复用其 cookie-free 形态,但报销详情还需返回准确 `fileSize` 和带扩展名的 `originalName`。
|
||||
3. 报销附件当前仅支持带 OA Bearer token 的 Blob 下载;企业微信原生请求不会携带该请求头,需要增加对象级授权后的短时签名 URL,不能改为公开文件。
|
||||
4. 签名 URL 必须短时、限定文件与申请人、保留实际打开审计;不得把长期 token、Cookie、Secret 或原始企微凭据放入 URL。
|
||||
5. 移动企微内调用 `previewFile`;普通 PC 浏览器使用签名 URL 的浏览器预览。非企微移动浏览器可保留明确下载或浏览器预览 fallback。
|
||||
|
||||
## 建议验证顺序
|
||||
|
||||
1. 先用不含业务数据的安全 PDF,在生产同域受控页面完成 JS-SDK `config + previewFile` 技术探针,验证马建勇实际 Android/iOS 企业微信版本。
|
||||
2. 探针通过后再接发票短时 URL和附件短时 URL,避免拿真实票据做首轮 SDK 调试。
|
||||
3. 合成数据覆盖:签名 URL 权限、过期/篡改拒绝、准确 size/name、企微原生调用、PC fallback、审批人只读边界。
|
||||
4. 生产部署后先用安全 PDF 验收,再让经理查看真实报销材料;材料可见前不得审批。
|
||||
|
||||
## 行业实现归纳
|
||||
|
||||
企业微信 H5 的常见稳妥实现是“JS-SDK 原生预览 + cookie-free 短时签名 URL + Web fallback”。其关键不在 PDF 渲染库,而在把受保护文件临时转换成原生层可拉取、可过期、可审计的 URL。PDF.js 或服务端转图片适合作为官方原生接口在特定机型失败后的第二级 fallback,不应作为本项目首选路径。
|
||||
+7
@@ -14,6 +14,7 @@
|
||||
"@radix-ui/react-slot": "^1.2.4",
|
||||
"@radix-ui/react-tabs": "^1.1.13",
|
||||
"@tanstack/react-query": "^5.62.0",
|
||||
"@wecom/jssdk": "2.4.2",
|
||||
"@zxing/browser": "^0.1.5",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
@@ -3486,6 +3487,12 @@
|
||||
"vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@wecom/jssdk": {
|
||||
"version": "2.4.2",
|
||||
"resolved": "https://registry.npmjs.org/@wecom/jssdk/-/jssdk-2.4.2.tgz",
|
||||
"integrity": "sha512-zlvP0fpkfUjStQNCLynOZyk9jGtnYvbqI94e3/86EyAJexY0X/Bb6bzgTvrFFa6jpz8LG2DXTYSnFhibneWG1g==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@zxing/browser": {
|
||||
"version": "0.1.5",
|
||||
"resolved": "https://registry.npmjs.org/@zxing/browser/-/browser-0.1.5.tgz",
|
||||
|
||||
@@ -31,6 +31,7 @@
|
||||
"@radix-ui/react-slot": "^1.2.4",
|
||||
"@radix-ui/react-tabs": "^1.1.13",
|
||||
"@tanstack/react-query": "^5.62.0",
|
||||
"@wecom/jssdk": "2.4.2",
|
||||
"@zxing/browser": "^0.1.5",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
|
||||
@@ -99,6 +99,20 @@ export async function fetchWecomConfig() {
|
||||
return apiRequestData<WecomEntryConfig>("/api/auth/wecom/config", { auth: false });
|
||||
}
|
||||
|
||||
export type WecomJsSdkSignature = {
|
||||
corpId: string;
|
||||
timestamp: number;
|
||||
nonceStr: string;
|
||||
signature: string;
|
||||
};
|
||||
|
||||
export async function fetchWecomJsSdkSignature(url: string) {
|
||||
return apiRequestData<WecomJsSdkSignature>("/api/auth/wecom/js-sdk-signature", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ url }),
|
||||
});
|
||||
}
|
||||
|
||||
export async function startWecomBind(returnTo = "/settings/profile") {
|
||||
return apiRequestData<{ loginUrl: string }>("/api/auth/wecom/bind", {
|
||||
method: "POST",
|
||||
|
||||
@@ -254,7 +254,13 @@ export async function bindInvoice(
|
||||
}
|
||||
|
||||
export async function fetchInvoiceSignedUrl(invoiceId: string) {
|
||||
return apiRequestData<{ signedUrl: string; expiresAt: string }>(
|
||||
return apiRequestData<{
|
||||
signedUrl: string;
|
||||
expiresAt: string;
|
||||
originalName: string | null;
|
||||
mimeType: string | null;
|
||||
fileSize: number | null;
|
||||
}>(
|
||||
`/api/invoice-pool/${invoiceId}/signed-url`,
|
||||
{
|
||||
method: "POST",
|
||||
|
||||
@@ -175,6 +175,8 @@ export type ReimbursementDetail = {
|
||||
sellerName: string | null;
|
||||
totalAmount: string | null;
|
||||
originalName: string | null;
|
||||
mimeType: string | null;
|
||||
fileSize: number | null;
|
||||
linkedItemId: string | null;
|
||||
}>;
|
||||
attachments: Array<{
|
||||
@@ -525,6 +527,23 @@ export async function downloadReimbursementAttachment(reimbursementId: string, a
|
||||
return apiRequestBlob(buildAttachmentFileUrl(reimbursementAttachmentFileUrl(reimbursementId, attachmentId), reason));
|
||||
}
|
||||
|
||||
export async function fetchReimbursementAttachmentPreviewUrl(
|
||||
reimbursementId: string,
|
||||
attachmentId: string,
|
||||
reason?: string,
|
||||
) {
|
||||
return apiRequestData<{
|
||||
signedUrl: string;
|
||||
expiresAt: string;
|
||||
originalName: string;
|
||||
mimeType: string;
|
||||
fileSize: number;
|
||||
}>(`/api/reimbursements/${reimbursementId}/attachments/${attachmentId}/preview-url`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify(reason ? { reason } : {}),
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
export async function linkReimbursementProject(reimbursementId: string, projectId: string | null) {
|
||||
return apiRequestData<{ projectId: string | null; project: { id: string; projectNo: string; name: string } | null }>(`/api/reimbursements/${reimbursementId}/link-project`, {
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
import { fetchWecomConfig, fetchWecomJsSdkSignature } from "../api/auth";
|
||||
import { isWecomWebView } from "./runtimeContext";
|
||||
|
||||
export type NativePreviewFile = {
|
||||
url: string;
|
||||
name: string | null | undefined;
|
||||
size: number | null | undefined;
|
||||
};
|
||||
|
||||
let configuredSdkPromise: Promise<typeof import("@wecom/jssdk")> | null = null;
|
||||
|
||||
function currentPageUrl() {
|
||||
const url = new URL(window.location.href);
|
||||
url.hash = "";
|
||||
return url.href;
|
||||
}
|
||||
|
||||
async function configuredWecomSdk() {
|
||||
if (!configuredSdkPromise) {
|
||||
configuredSdkPromise = Promise.all([import("@wecom/jssdk"), fetchWecomConfig()])
|
||||
.then(([ww, config]) => {
|
||||
if (!config.corpId) {
|
||||
throw new Error("企业微信文件预览未配置");
|
||||
}
|
||||
ww.register({
|
||||
corpId: config.corpId,
|
||||
jsApiList: ["previewFile"],
|
||||
async getConfigSignature(url) {
|
||||
const signed = await fetchWecomJsSdkSignature(url || currentPageUrl());
|
||||
return {
|
||||
timestamp: signed.timestamp,
|
||||
nonceStr: signed.nonceStr,
|
||||
signature: signed.signature,
|
||||
};
|
||||
},
|
||||
});
|
||||
return ww;
|
||||
})
|
||||
.catch((error) => {
|
||||
configuredSdkPromise = null;
|
||||
throw error;
|
||||
});
|
||||
}
|
||||
return configuredSdkPromise;
|
||||
}
|
||||
|
||||
export async function previewFileInWecom(file: NativePreviewFile) {
|
||||
if (!isWecomWebView()) {
|
||||
return false;
|
||||
}
|
||||
const name = file.name?.trim();
|
||||
const size = Number(file.size);
|
||||
if (!name || !/\.[a-z0-9]{1,10}$/i.test(name) || !Number.isSafeInteger(size) || size <= 0) {
|
||||
throw new Error("文件信息不完整,暂时无法预览");
|
||||
}
|
||||
const ww = await configuredWecomSdk();
|
||||
await ww.previewFile({ url: file.url, name, size });
|
||||
return true;
|
||||
}
|
||||
|
||||
export function openFileInBrowser(url: string) {
|
||||
window.open(url, "_blank", "noopener,noreferrer");
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
archiveInvoice,
|
||||
bindInvoice,
|
||||
fetchBindableInvoices,
|
||||
fetchInvoiceSignedUrl,
|
||||
fetchInvoicePoolList,
|
||||
unbindInvoice,
|
||||
uploadInvoice,
|
||||
@@ -19,6 +20,7 @@ import {
|
||||
deleteReimbursement,
|
||||
deleteReimbursementItem,
|
||||
downloadReimbursementAttachment,
|
||||
fetchReimbursementAttachmentPreviewUrl,
|
||||
fetchReimbursementDetail,
|
||||
MAX_REIMBURSEMENT_ATTACHMENT_FILES,
|
||||
precheckReimbursement,
|
||||
@@ -65,6 +67,8 @@ import { TEST_IDS } from "../lib/testIds";
|
||||
import { withUploadTimeout } from "../lib/uploadTimeout";
|
||||
import { useInvoiceOcrTracker } from "../hooks/useInvoiceOcrTracker";
|
||||
import { cn } from "../lib/utils";
|
||||
import { isWecomWebView } from "../lib/runtimeContext";
|
||||
import { openFileInBrowser, previewFileInWecom } from "../lib/wecomFilePreview";
|
||||
|
||||
type ItemFormState = {
|
||||
mode: "create" | "edit";
|
||||
@@ -678,10 +682,37 @@ export function MobileReimbursementDetailPage() {
|
||||
|
||||
const { requestDownloadReason, downloadReasonDialog } = useAttachmentDownloadReasonDialog();
|
||||
|
||||
const openInvoiceMutation = useMutation({
|
||||
mutationFn: async (invoiceId: string) => {
|
||||
const result = await fetchInvoiceSignedUrl(invoiceId);
|
||||
const openedInWecom = await previewFileInWecom({
|
||||
url: result.signedUrl,
|
||||
name: result.originalName,
|
||||
size: result.fileSize,
|
||||
});
|
||||
if (!openedInWecom) {
|
||||
openFileInBrowser(result.signedUrl);
|
||||
}
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(resolveErrorMessage(error));
|
||||
},
|
||||
});
|
||||
|
||||
const openAttachmentMutation = useMutation({
|
||||
mutationFn: ({ attachmentId, reason }: { attachmentId: string; reason?: string }) =>
|
||||
downloadReimbursementAttachment(reimbursementId, attachmentId, reason),
|
||||
mutationFn: async ({ attachment, reason }: {
|
||||
attachment: { id: string; originalName: string; fileSize: number } & AttachmentGovernanceFields;
|
||||
reason?: string;
|
||||
}) => {
|
||||
if (isWecomWebView()) {
|
||||
const result = await fetchReimbursementAttachmentPreviewUrl(reimbursementId, attachment.id, reason);
|
||||
await previewFileInWecom({ url: result.signedUrl, name: result.originalName, size: result.fileSize });
|
||||
return null;
|
||||
}
|
||||
return downloadReimbursementAttachment(reimbursementId, attachment.id, reason);
|
||||
},
|
||||
onSuccess: (result) => {
|
||||
if (!result) return;
|
||||
const url = URL.createObjectURL(result.blob);
|
||||
window.open(url, "_blank", "noopener,noreferrer");
|
||||
window.setTimeout(() => URL.revokeObjectURL(url), 60_000);
|
||||
@@ -691,12 +722,12 @@ export function MobileReimbursementDetailPage() {
|
||||
},
|
||||
});
|
||||
|
||||
async function handleOpenAttachment(attachment: { id: string; originalName?: string } & AttachmentGovernanceFields) {
|
||||
async function handleOpenAttachment(attachment: { id: string; originalName: string; fileSize: number } & AttachmentGovernanceFields) {
|
||||
const reason = await requestDownloadReason(attachment);
|
||||
if (reason === null) {
|
||||
return;
|
||||
}
|
||||
openAttachmentMutation.mutate({ attachmentId: attachment.id, reason });
|
||||
openAttachmentMutation.mutate({ attachment, reason });
|
||||
}
|
||||
|
||||
const handleSubmit = async () => {
|
||||
@@ -1028,6 +1059,16 @@ export function MobileReimbursementDetailPage() {
|
||||
移除
|
||||
</Button>
|
||||
) : null}
|
||||
<Button
|
||||
className="mt-3 w-full"
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
type="button"
|
||||
disabled={openInvoiceMutation.isPending}
|
||||
onClick={() => openInvoiceMutation.mutate(invoice.id)}
|
||||
>
|
||||
查看原件
|
||||
</Button>
|
||||
</article>
|
||||
);
|
||||
})}
|
||||
@@ -1117,7 +1158,7 @@ export function MobileReimbursementDetailPage() {
|
||||
disabled={openAttachmentMutation.isPending}
|
||||
onClick={() => void handleOpenAttachment(attachment)}
|
||||
>
|
||||
查看
|
||||
查看原件
|
||||
</Button>
|
||||
{canManageAttachments ? (
|
||||
<Button
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
deleteReimbursement,
|
||||
deleteReimbursementItem,
|
||||
downloadReimbursementAttachment,
|
||||
fetchReimbursementAttachmentPreviewUrl,
|
||||
fetchReimbursementDetail,
|
||||
MAX_REIMBURSEMENT_ATTACHMENT_FILES,
|
||||
precheckReimbursement,
|
||||
@@ -26,10 +27,13 @@ import { remindWorkflowTask } from "../api/workflow";
|
||||
import {
|
||||
bindInvoice,
|
||||
fetchBindableInvoices,
|
||||
fetchInvoiceSignedUrl,
|
||||
unbindInvoice,
|
||||
uploadInvoice,
|
||||
} from "../api/invoicePool";
|
||||
import { toast } from "sonner";
|
||||
import { isWecomWebView } from "../lib/runtimeContext";
|
||||
import { openFileInBrowser, previewFileInWecom } from "../lib/wecomFilePreview";
|
||||
import { useAuth } from "../auth/AuthContext";
|
||||
import { hasPermission } from "../auth/permissions";
|
||||
import { CardSkeleton } from "../components/common/CardSkeleton";
|
||||
@@ -674,10 +678,37 @@ export function ReimbursementDetailPage() {
|
||||
|
||||
const { requestDownloadReason, downloadReasonDialog } = useAttachmentDownloadReasonDialog();
|
||||
|
||||
const openInvoiceMutation = useMutation({
|
||||
mutationFn: async (invoiceId: string) => {
|
||||
const result = await fetchInvoiceSignedUrl(invoiceId);
|
||||
const openedInWecom = await previewFileInWecom({
|
||||
url: result.signedUrl,
|
||||
name: result.originalName,
|
||||
size: result.fileSize,
|
||||
});
|
||||
if (!openedInWecom) {
|
||||
openFileInBrowser(result.signedUrl);
|
||||
}
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(buildActionErrorMessage(error, "打开发票失败"));
|
||||
},
|
||||
});
|
||||
|
||||
const openAttachmentMutation = useMutation({
|
||||
mutationFn: ({ attachmentId, reason }: { attachmentId: string; reason?: string }) =>
|
||||
downloadReimbursementAttachment(reimbursementId, attachmentId, reason),
|
||||
mutationFn: async ({ attachment, reason }: {
|
||||
attachment: { id: string; originalName: string; fileSize: number } & AttachmentGovernanceFields;
|
||||
reason?: string;
|
||||
}) => {
|
||||
if (isWecomWebView()) {
|
||||
const result = await fetchReimbursementAttachmentPreviewUrl(reimbursementId, attachment.id, reason);
|
||||
await previewFileInWecom({ url: result.signedUrl, name: result.originalName, size: result.fileSize });
|
||||
return null;
|
||||
}
|
||||
return downloadReimbursementAttachment(reimbursementId, attachment.id, reason);
|
||||
},
|
||||
onSuccess: (result) => {
|
||||
if (!result) return;
|
||||
const url = URL.createObjectURL(result.blob);
|
||||
window.open(url, "_blank", "noopener,noreferrer");
|
||||
window.setTimeout(() => URL.revokeObjectURL(url), 60_000);
|
||||
@@ -687,12 +718,12 @@ export function ReimbursementDetailPage() {
|
||||
},
|
||||
});
|
||||
|
||||
async function handleOpenAttachment(attachment: { id: string; originalName?: string } & AttachmentGovernanceFields) {
|
||||
async function handleOpenAttachment(attachment: { id: string; originalName: string; fileSize: number } & AttachmentGovernanceFields) {
|
||||
const reason = await requestDownloadReason(attachment);
|
||||
if (reason === null) {
|
||||
return;
|
||||
}
|
||||
openAttachmentMutation.mutate({ attachmentId: attachment.id, reason });
|
||||
openAttachmentMutation.mutate({ attachment, reason });
|
||||
}
|
||||
|
||||
if (!reimbursementId) {
|
||||
@@ -1423,6 +1454,15 @@ export function ReimbursementDetailPage() {
|
||||
<span className="badge-neutral">
|
||||
{resolveInvoiceStatusLabel(item.status)}
|
||||
</span>
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
disabled={openInvoiceMutation.isPending}
|
||||
onClick={() => openInvoiceMutation.mutate(item.id)}
|
||||
>
|
||||
查看原件
|
||||
</Button>
|
||||
{canUnbindInvoices ? (
|
||||
<Button
|
||||
type="button"
|
||||
@@ -1515,7 +1555,7 @@ export function ReimbursementDetailPage() {
|
||||
disabled={openAttachmentMutation.isPending}
|
||||
onClick={() => void handleOpenAttachment(attachment)}
|
||||
>
|
||||
查看
|
||||
查看原件
|
||||
</Button>
|
||||
{canEditItems ? (
|
||||
<Button
|
||||
|
||||
@@ -78,6 +78,8 @@ function buildInvoice() {
|
||||
sellerName: "北京悦海堂餐饮管理有限公司",
|
||||
totalAmount: "1683.00",
|
||||
originalName: "北京悦海堂发票.pdf",
|
||||
mimeType: "application/pdf",
|
||||
fileSize: 4096,
|
||||
linkedItemId: "item-r111",
|
||||
};
|
||||
}
|
||||
@@ -223,6 +225,16 @@ async function mockApis(page: Page, state: ReturnType<typeof buildReimbursementS
|
||||
return;
|
||||
}
|
||||
|
||||
if (pathName === "/api/auth/wecom/js-sdk-signature" && request.method() === "POST") {
|
||||
await fulfillJson(route, {
|
||||
corpId: "ww-test",
|
||||
timestamp: 1785484800,
|
||||
nonceStr: "synthetic-r111-nonce",
|
||||
signature: "1111111111111111111111111111111111111111",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (pathName === "/api/auth/login" || pathName === "/api/auth/refresh") {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
@@ -327,6 +339,33 @@ async function mockApis(page: Page, state: ReturnType<typeof buildReimbursementS
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
pathName === `/api/reimbursements/${reimbursementId}/attachments/${attachmentId}/preview-url`
|
||||
&& request.method() === "POST"
|
||||
) {
|
||||
await fulfillJson(route, {
|
||||
signedUrl: `https://oa.hyinda.com/api/reimbursements/${reimbursementId}/attachments/${attachmentId}/file?previewToken=synthetic`,
|
||||
expiresAt: "2026-07-31T10:02:00.000Z",
|
||||
originalName: "付款截图.png",
|
||||
mimeType: "image/png",
|
||||
fileSize: 2048,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
pathName === `/api/reimbursements/${reimbursementId}/attachments/${attachmentId}/file`
|
||||
&& request.method() === "GET"
|
||||
) {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: "image/png",
|
||||
headers: { "content-disposition": 'inline; filename="attachment.png"' },
|
||||
body: Buffer.from("synthetic-r111-attachment"),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (pathName === "/api/invoice-pool/bindable") {
|
||||
await fulfillJson(route, {
|
||||
items: state.availableInvoices,
|
||||
@@ -405,6 +444,17 @@ async function mockApis(page: Page, state: ReturnType<typeof buildReimbursementS
|
||||
return;
|
||||
}
|
||||
|
||||
if (pathName === `/api/invoice-pool/${pendingInvoiceId}/signed-url` && request.method() === "POST") {
|
||||
await fulfillJson(route, {
|
||||
signedUrl: `https://oa.hyinda.com/api/invoice-pool/${pendingInvoiceId}/file?token=synthetic`,
|
||||
expiresAt: "2026-07-31T10:02:00.000Z",
|
||||
originalName: "北京悦海堂发票.pdf",
|
||||
mimeType: "application/pdf",
|
||||
fileSize: 4096,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (pathName === `/api/invoice-pool/${pendingInvoiceId}/unbind`) {
|
||||
state.invoices = [];
|
||||
await fulfillJson(route, {
|
||||
@@ -515,6 +565,14 @@ async function collectVisualMetrics(page: Page) {
|
||||
test.describe("R111 国内报销发票与附件双端体验", () => {
|
||||
test("PC 报销详情上传发票后自动添加,并提供附件材料", async ({ page }) => {
|
||||
const state = buildReimbursementState();
|
||||
await page.addInitScript(() => {
|
||||
const openCalls: unknown[][] = [];
|
||||
Object.defineProperty(window, "__r111OpenCalls", { value: openCalls, configurable: true });
|
||||
window.open = ((...args: unknown[]) => {
|
||||
openCalls.push(args);
|
||||
return null;
|
||||
}) as typeof window.open;
|
||||
});
|
||||
await mockApis(page, state);
|
||||
await login(page);
|
||||
|
||||
@@ -540,6 +598,12 @@ test.describe("R111 国内报销发票与附件双端体验", () => {
|
||||
await page.keyboard.press("Escape");
|
||||
await expect(page.getByText("北京悦海堂餐饮管理有限公司")).toBeVisible();
|
||||
await expect(page.getByText("明细:业务招待 · 客户招待")).toBeVisible();
|
||||
await page.getByRole("button", { name: "查看原件" }).first().click();
|
||||
await expect.poll(() => page.evaluate(() => (window as typeof window & { __r111OpenCalls: unknown[][] }).__r111OpenCalls.length)).toBe(1);
|
||||
const invoiceOpenUrl = await page.evaluate(
|
||||
() => String((window as typeof window & { __r111OpenCalls: unknown[][] }).__r111OpenCalls[0]?.[0] ?? ""),
|
||||
);
|
||||
expect(invoiceOpenUrl).toContain(`/api/invoice-pool/${pendingInvoiceId}/file?token=`);
|
||||
|
||||
await page.locator("#reimbursement-attachment-upload").setInputFiles({
|
||||
name: "付款截图.png",
|
||||
@@ -548,6 +612,8 @@ test.describe("R111 国内报销发票与附件双端体验", () => {
|
||||
});
|
||||
await page.getByRole("button", { name: "上传附件" }).click();
|
||||
await expect(page.getByText("付款截图.png")).toBeVisible();
|
||||
await page.getByTestId("reimbursement-attachments").getByRole("button", { name: "查看原件" }).click();
|
||||
await expect.poll(() => page.evaluate(() => (window as typeof window & { __r111OpenCalls: unknown[][] }).__r111OpenCalls.length)).toBe(2);
|
||||
|
||||
const bodyText = await page.locator("body").innerText();
|
||||
expect(bodyText).not.toContain("绑定整单");
|
||||
@@ -564,6 +630,26 @@ test.describe("R111 国内报销发票与附件双端体验", () => {
|
||||
test("企业微信手机端保持移动布局,发票和附件能力与 PC 一致", async ({ browser }) => {
|
||||
const state = buildReimbursementState();
|
||||
const mobile = await newWecomPage(browser);
|
||||
await mobile.page.addInitScript(() => {
|
||||
const bridgeCalls: Array<{ name: string; params: Record<string, unknown> }> = [];
|
||||
Object.defineProperty(window, "__r111BridgeCalls", { value: bridgeCalls, configurable: true });
|
||||
Object.defineProperty(window, "WeixinJSBridge", {
|
||||
configurable: true,
|
||||
value: {
|
||||
invoke(name: string, params: Record<string, unknown>, callback: (result: Record<string, unknown>) => void) {
|
||||
bridgeCalls.push({ name, params });
|
||||
callback({ err_msg: `${name}:ok` });
|
||||
},
|
||||
on() {},
|
||||
},
|
||||
});
|
||||
const openCalls: unknown[][] = [];
|
||||
Object.defineProperty(window, "__r111OpenCalls", { value: openCalls, configurable: true });
|
||||
window.open = ((...args: unknown[]) => {
|
||||
openCalls.push(args);
|
||||
return null;
|
||||
}) as typeof window.open;
|
||||
});
|
||||
await mockApis(mobile.page, state);
|
||||
|
||||
try {
|
||||
@@ -627,6 +713,10 @@ test.describe("R111 国内报销发票与附件双端体验", () => {
|
||||
).toBeVisible({ timeout: 15_000 });
|
||||
await mobile.page.keyboard.press("Escape");
|
||||
await expect(mobile.page.getByText("北京悦海堂餐饮管理有限公司")).toBeVisible();
|
||||
await mobile.page.getByRole("button", { name: "查看原件" }).first().click();
|
||||
await expect.poll(() => mobile.page.evaluate(() => (
|
||||
window as typeof window & { __r111BridgeCalls: Array<{ name: string }> }
|
||||
).__r111BridgeCalls.filter((call) => call.name === "previewFile").length)).toBe(1);
|
||||
|
||||
await mobile.page.getByTestId("mobile-reimbursement-attachments").getByRole("button", { name: "选择" }).click();
|
||||
await mobile.page.locator("[data-testid='mobile-reimbursement-attachments'] input[type='file']").setInputFiles({
|
||||
@@ -636,6 +726,32 @@ test.describe("R111 国内报销发票与附件双端体验", () => {
|
||||
});
|
||||
await mobile.page.getByTestId("mobile-reimbursement-attachments").getByRole("button", { name: "上传附件" }).click();
|
||||
await expect(mobile.page.getByText("付款截图.png")).toBeVisible();
|
||||
await mobile.page.getByTestId("mobile-reimbursement-attachments").getByRole("button", { name: "查看原件" }).click();
|
||||
await expect.poll(() => mobile.page.evaluate(() => (
|
||||
window as typeof window & { __r111BridgeCalls: Array<{ name: string }> }
|
||||
).__r111BridgeCalls.filter((call) => call.name === "previewFile").length)).toBe(2);
|
||||
const nativePreviewCalls = await mobile.page.evaluate(() => (
|
||||
window as typeof window & { __r111BridgeCalls: Array<{ name: string; params: Record<string, unknown> }> }
|
||||
).__r111BridgeCalls.filter((call) => call.name === "previewFile"));
|
||||
expect(nativePreviewCalls).toEqual([
|
||||
expect.objectContaining({
|
||||
params: expect.objectContaining({
|
||||
url: expect.stringContaining(`/api/invoice-pool/${pendingInvoiceId}/file?token=`),
|
||||
name: "北京悦海堂发票.pdf",
|
||||
size: 4096,
|
||||
}),
|
||||
}),
|
||||
expect.objectContaining({
|
||||
params: expect.objectContaining({
|
||||
url: expect.stringContaining(`/attachments/${attachmentId}/file?previewToken=`),
|
||||
name: "付款截图.png",
|
||||
size: 2048,
|
||||
}),
|
||||
}),
|
||||
]);
|
||||
expect(await mobile.page.evaluate(() => (
|
||||
window as typeof window & { __r111OpenCalls: unknown[][] }
|
||||
).__r111OpenCalls.length)).toBe(0);
|
||||
|
||||
const bodyText = await mobile.page.locator("body").innerText();
|
||||
expect(bodyText).not.toContain("绑定整单");
|
||||
|
||||
@@ -105,6 +105,19 @@ function resolveInvoiceStatusReason(invoice) {
|
||||
return resolveInvoiceAvailability(invoice);
|
||||
}
|
||||
|
||||
function resolveSignedFileOrigin(config, request) {
|
||||
const configured = String(config.appOrigin ?? "")
|
||||
.split(",")
|
||||
.map((item) => item.trim())
|
||||
.find(Boolean);
|
||||
return configured || `${request.protocol}://${request.headers.host ?? "localhost:3000"}`;
|
||||
}
|
||||
|
||||
function resolvePreviewUrlTtlMs(config) {
|
||||
const configuredSeconds = Math.max(30, Number(config.signedUrlTtlSeconds ?? 120));
|
||||
return Math.min(configuredSeconds, 120) * 1000;
|
||||
}
|
||||
|
||||
function ensureInvoiceReadableByUser(request, invoice) {
|
||||
const roleCodes = request.employee.roleCodes;
|
||||
const permissionCodes = request.employee.permissionCodes ?? [];
|
||||
@@ -179,6 +192,9 @@ function registerInvoicePoolReadRoutes(fastify) {
|
||||
owner_employee_id: true,
|
||||
uploaded_by_employee_id: true,
|
||||
linked_reimbursement_id: true,
|
||||
original_name: true,
|
||||
mime_type: true,
|
||||
file_size: true,
|
||||
linked_item_id: true,
|
||||
travel_order_id: true,
|
||||
invoice_code: true,
|
||||
@@ -467,6 +483,9 @@ function registerInvoicePoolReadRoutes(fastify) {
|
||||
owner_employee_id: true,
|
||||
uploaded_by_employee_id: true,
|
||||
linked_reimbursement_id: true,
|
||||
original_name: true,
|
||||
mime_type: true,
|
||||
file_size: true,
|
||||
linked_reimbursement: {
|
||||
select: {
|
||||
owner_id: true,
|
||||
@@ -497,7 +516,7 @@ function registerInvoicePoolReadRoutes(fastify) {
|
||||
});
|
||||
}
|
||||
|
||||
const expiresAt = Date.now() + fastify.config.signedUrlTtlSeconds * 1000;
|
||||
const expiresAt = Date.now() + resolvePreviewUrlTtlMs(fastify.config);
|
||||
const token = signPayload(
|
||||
{
|
||||
invoiceId: invoice.id,
|
||||
@@ -507,9 +526,7 @@ function registerInvoicePoolReadRoutes(fastify) {
|
||||
fastify.config.jwtSecret,
|
||||
);
|
||||
|
||||
const origin =
|
||||
fastify.config.appOrigin ||
|
||||
`${request.protocol}://${request.headers.host ?? "localhost:3000"}`;
|
||||
const origin = resolveSignedFileOrigin(fastify.config, request);
|
||||
const signedUrl = `${origin}/api/invoice-pool/${invoice.id}/file?token=${encodeURIComponent(token)}`;
|
||||
|
||||
await fastify.prisma.auditLog.create({
|
||||
@@ -528,6 +545,9 @@ function registerInvoicePoolReadRoutes(fastify) {
|
||||
return sendData(reply, request, {
|
||||
signedUrl,
|
||||
expiresAt: new Date(expiresAt).toISOString(),
|
||||
originalName: invoice.original_name,
|
||||
mimeType: invoice.mime_type,
|
||||
fileSize: invoice.file_size,
|
||||
});
|
||||
},
|
||||
);
|
||||
@@ -610,24 +630,22 @@ function registerInvoicePoolReadRoutes(fastify) {
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
await fastify.prisma.auditLog.create({
|
||||
data: buildAuditLogData(request, {
|
||||
actorId,
|
||||
action: "INVOICE_FILE_DOWNLOADED",
|
||||
targetType: "invoice_pool",
|
||||
targetId: parsedInvoiceId.data,
|
||||
extra: {
|
||||
invoiceId: parsedInvoiceId.data,
|
||||
exp: payload.exp,
|
||||
},
|
||||
}),
|
||||
});
|
||||
} catch (error) {
|
||||
fastify.log.error({ err: error, invoiceId: parsedInvoiceId.data }, "Audit log write failed");
|
||||
}
|
||||
await fastify.prisma.auditLog.create({
|
||||
data: buildAuditLogData(request, {
|
||||
actorId,
|
||||
action: "INVOICE_FILE_DOWNLOADED",
|
||||
targetType: "invoice_pool",
|
||||
targetId: parsedInvoiceId.data,
|
||||
extra: {
|
||||
invoiceId: parsedInvoiceId.data,
|
||||
exp: payload.exp,
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
reply.type(invoice.mime_type || "application/octet-stream");
|
||||
reply.header("cache-control", "private, no-store");
|
||||
reply.header("referrer-policy", "no-referrer");
|
||||
reply.header(
|
||||
"content-disposition",
|
||||
buildAttachmentContentDisposition(invoice.original_name, { disposition: "inline" }),
|
||||
|
||||
+140
-13
@@ -7,6 +7,7 @@ const { sendData } = require("../utils/reply");
|
||||
const { buildAuditLogData } = require("../utils/audit");
|
||||
const { sha256Hex } = require("../utils/crypto");
|
||||
const { buildAttachmentContentDisposition } = require("../utils/contentDisposition");
|
||||
const { signPayload, verifyToken } = require("../utils/signedUrl");
|
||||
const { compactDateString, currentDateString, parseDateOnlyString } = require("../utils/date");
|
||||
const { findActiveDelegationForDelegate, listActiveDelegationPrincipals } = require("../utils/delegation");
|
||||
const { hasPermission, resolveEntityPermission } = require("../constants/permissions");
|
||||
@@ -76,6 +77,22 @@ const REIMBURSEMENT_ATTACHMENT_DELETE_BODY_SCHEMA = z
|
||||
reason: z.string().trim().min(2).max(200),
|
||||
})
|
||||
.strict();
|
||||
const REIMBURSEMENT_ATTACHMENT_PREVIEW_BODY_SCHEMA = z
|
||||
.object({ reason: z.string().trim().max(200).optional() })
|
||||
.strict();
|
||||
|
||||
function resolveSignedFileOrigin(config, request) {
|
||||
const configured = String(config.appOrigin ?? "")
|
||||
.split(",")
|
||||
.map((item) => item.trim())
|
||||
.find(Boolean);
|
||||
return configured || `${request.protocol}://${request.headers.host ?? "localhost:3000"}`;
|
||||
}
|
||||
|
||||
function resolvePreviewUrlTtlMs(config) {
|
||||
const configuredSeconds = Math.max(30, Number(config.signedUrlTtlSeconds ?? 120));
|
||||
return Math.min(configuredSeconds, 120) * 1000;
|
||||
}
|
||||
|
||||
const REIMBURSEMENT_CREATE_BODY_SCHEMA = z
|
||||
.object({
|
||||
@@ -2106,6 +2123,8 @@ async function reimbursementRoutes(fastify) {
|
||||
seller_name: true,
|
||||
total_amount: true,
|
||||
original_name: true,
|
||||
mime_type: true,
|
||||
file_size: true,
|
||||
linked_item_id: true,
|
||||
},
|
||||
},
|
||||
@@ -2280,6 +2299,8 @@ async function reimbursementRoutes(fastify) {
|
||||
sellerName: item.seller_name ?? null,
|
||||
totalAmount: toMoneyString(item.total_amount),
|
||||
originalName: item.original_name ?? null,
|
||||
mimeType: item.mime_type ?? null,
|
||||
fileSize: item.file_size ?? null,
|
||||
linkedItemId: item.linked_item_id ?? null,
|
||||
})),
|
||||
pendingWorkflow: pendingInstance
|
||||
@@ -2725,9 +2746,7 @@ async function reimbursementRoutes(fastify) {
|
||||
|
||||
fastify.get(
|
||||
"/reimbursements/:id/attachments/:attachmentId/file",
|
||||
{
|
||||
preHandler: fastify.authenticate,
|
||||
},
|
||||
{},
|
||||
async (request, reply) => {
|
||||
const reimbursementId = parseReimbursementId(request.params?.id);
|
||||
const parsedAttachmentId = UUID_SCHEMA.safeParse(request.params?.attachmentId);
|
||||
@@ -2740,6 +2759,28 @@ async function reimbursementRoutes(fastify) {
|
||||
});
|
||||
}
|
||||
|
||||
const previewToken = typeof request.query?.previewToken === "string" ? request.query.previewToken : "";
|
||||
let previewGrant = null;
|
||||
if (previewToken) {
|
||||
const verified = verifyToken(previewToken, fastify.config.jwtSecret);
|
||||
const payload = verified.ok ? verified.payload : null;
|
||||
const parsedActorId = UUID_SCHEMA.safeParse(payload?.actorId);
|
||||
if (
|
||||
!verified.ok ||
|
||||
payload?.purpose !== "wecom_preview" ||
|
||||
payload?.reimbursementId !== reimbursementId ||
|
||||
payload?.attachmentId !== parsedAttachmentId.data ||
|
||||
typeof payload?.exp !== "number" ||
|
||||
payload.exp <= Date.now() ||
|
||||
!parsedActorId.success
|
||||
) {
|
||||
throw new AppError({ code: "PERM_001", message: "无权限", statusCode: 403 });
|
||||
}
|
||||
previewGrant = { actorId: parsedActorId.data };
|
||||
} else {
|
||||
await fastify.authenticate(request);
|
||||
}
|
||||
|
||||
const attachment = await fastify.prisma.reimbursementAttachment.findFirst({
|
||||
where: {
|
||||
id: parsedAttachmentId.data,
|
||||
@@ -2750,6 +2791,7 @@ async function reimbursementRoutes(fastify) {
|
||||
storage_path: true,
|
||||
original_name: true,
|
||||
mime_type: true,
|
||||
file_size: true,
|
||||
governance_status: true,
|
||||
sensitivity_level: true,
|
||||
attachment_type: true,
|
||||
@@ -2770,15 +2812,23 @@ 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 actorId;
|
||||
let downloadReason;
|
||||
if (previewGrant) {
|
||||
actorId = previewGrant.actorId;
|
||||
downloadReason = "企业微信在线预览";
|
||||
} else {
|
||||
ensureOwnOrCanViewAll(fastify, request, attachment.reimbursement_form);
|
||||
actorId = request.employee.id;
|
||||
downloadReason = sanitizeDownloadReason(request.query?.reason);
|
||||
if (requiresDownloadReason(attachment) && !downloadReason) {
|
||||
throw new AppError({
|
||||
code: "VALID_001",
|
||||
message: "参数校验失败",
|
||||
statusCode: 400,
|
||||
details: { reason: "required" },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
let stream = null;
|
||||
@@ -2801,7 +2851,7 @@ async function reimbursementRoutes(fastify) {
|
||||
|
||||
await fastify.prisma.auditLog.create({
|
||||
data: buildAuditLogData(request, {
|
||||
actorId: request.employee.id,
|
||||
actorId,
|
||||
action: "REIMBURSEMENT_ATTACHMENT_DOWNLOADED",
|
||||
targetType: "reimbursement",
|
||||
targetId: reimbursementId,
|
||||
@@ -2816,6 +2866,8 @@ async function reimbursementRoutes(fastify) {
|
||||
});
|
||||
|
||||
reply.type(attachment.mime_type || "application/octet-stream");
|
||||
reply.header("cache-control", "private, no-store");
|
||||
reply.header("referrer-policy", "no-referrer");
|
||||
reply.header(
|
||||
"content-disposition",
|
||||
buildAttachmentContentDisposition(attachment.original_name, { disposition: "inline" }),
|
||||
@@ -2824,6 +2876,81 @@ async function reimbursementRoutes(fastify) {
|
||||
},
|
||||
);
|
||||
|
||||
fastify.post(
|
||||
"/reimbursements/:id/attachments/:attachmentId/preview-url",
|
||||
{ preHandler: fastify.authenticate },
|
||||
async (request, reply) => {
|
||||
const reimbursementId = parseReimbursementId(request.params?.id);
|
||||
const parsedAttachmentId = UUID_SCHEMA.safeParse(request.params?.attachmentId);
|
||||
const parsedBody = REIMBURSEMENT_ATTACHMENT_PREVIEW_BODY_SCHEMA.safeParse(request.body ?? {});
|
||||
if (!parsedAttachmentId.success || !parsedBody.success) {
|
||||
throw new AppError({ code: "VALID_001", message: "参数校验失败", statusCode: 400 });
|
||||
}
|
||||
const attachment = await fastify.prisma.reimbursementAttachment.findFirst({
|
||||
where: { id: parsedAttachmentId.data, reimbursement_id: reimbursementId },
|
||||
select: {
|
||||
id: true,
|
||||
original_name: true,
|
||||
mime_type: true,
|
||||
file_size: true,
|
||||
governance_status: true,
|
||||
sensitivity_level: true,
|
||||
attachment_type: true,
|
||||
download_policy: true,
|
||||
reimbursement_form: { select: { owner_id: true, submitter_id: true } },
|
||||
},
|
||||
});
|
||||
if (!attachment) {
|
||||
throw new AppError({ code: "SYS_404", message: "Not Found", statusCode: 404 });
|
||||
}
|
||||
ensureOwnOrCanViewAll(fastify, request, attachment.reimbursement_form);
|
||||
const reason = sanitizeDownloadReason(parsedBody.data.reason);
|
||||
if (requiresDownloadReason(attachment) && !reason) {
|
||||
throw new AppError({
|
||||
code: "VALID_001",
|
||||
message: "参数校验失败",
|
||||
statusCode: 400,
|
||||
details: { reason: "required" },
|
||||
});
|
||||
}
|
||||
|
||||
const expiresAt = Date.now() + resolvePreviewUrlTtlMs(fastify.config);
|
||||
const previewToken = signPayload({
|
||||
purpose: "wecom_preview",
|
||||
reimbursementId,
|
||||
attachmentId: attachment.id,
|
||||
actorId: request.employee.id,
|
||||
exp: expiresAt,
|
||||
}, fastify.config.jwtSecret);
|
||||
const origin = resolveSignedFileOrigin(fastify.config, request);
|
||||
const signedUrl = `${origin}/api/reimbursements/${reimbursementId}/attachments/${attachment.id}/file?previewToken=${encodeURIComponent(previewToken)}`;
|
||||
|
||||
await fastify.prisma.auditLog.create({
|
||||
data: buildAuditLogData(request, {
|
||||
actorId: request.employee.id,
|
||||
action: "REIMBURSEMENT_ATTACHMENT_PREVIEW_URL_ISSUED",
|
||||
targetType: "reimbursement",
|
||||
targetId: reimbursementId,
|
||||
extra: {
|
||||
attachmentId: attachment.id,
|
||||
attachmentType: attachment.attachment_type ?? ATTACHMENT_TYPE.supportingMaterial,
|
||||
sensitivityLevel: attachment.sensitivity_level ?? SENSITIVITY_LEVEL.internal,
|
||||
reason,
|
||||
expiresAt,
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
return sendData(reply, request, {
|
||||
signedUrl,
|
||||
expiresAt: new Date(expiresAt).toISOString(),
|
||||
originalName: attachment.original_name,
|
||||
mimeType: attachment.mime_type,
|
||||
fileSize: attachment.file_size,
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
fastify.post(
|
||||
"/reimbursements/:id/items",
|
||||
{
|
||||
|
||||
@@ -16,6 +16,10 @@ const {
|
||||
autoBindExistingWecomEmployee,
|
||||
fetchWecomLoginIdentity,
|
||||
} = require("../services/integrations/wecomLoginAutoBinding");
|
||||
const {
|
||||
getWecomJsApiTicket,
|
||||
signWecomJsSdkUrl,
|
||||
} = require("../services/integrations/wecomJsSdk");
|
||||
|
||||
const BIND_COOKIE_NAME = "wecom_bind";
|
||||
const PENDING_LOGIN_BIND_COOKIE_NAME = "wecom_login_bind";
|
||||
@@ -34,6 +38,7 @@ const QUERY_SCHEMA = z.object({
|
||||
deviceFingerprint: z.string().trim().min(1).max(128).optional(),
|
||||
});
|
||||
const BIND_BODY_SCHEMA = z.object({ returnTo: z.string().optional() }).strict().optional();
|
||||
const JS_SDK_SIGNATURE_BODY_SCHEMA = z.object({ url: z.string().trim().url().max(2048) }).strict();
|
||||
|
||||
function refreshCookieOptions(config) {
|
||||
return {
|
||||
@@ -108,6 +113,23 @@ function resolvePrimaryAppOrigin(config, request) {
|
||||
return `${protocol}://${host}`;
|
||||
}
|
||||
|
||||
function normalizeJsSdkPageUrl(rawUrl, config, request) {
|
||||
let parsed;
|
||||
try {
|
||||
parsed = new URL(rawUrl);
|
||||
} catch {
|
||||
throw new AppError({ code: "VALID_001", message: "参数校验失败", statusCode: 400, details: { url: "invalid" } });
|
||||
}
|
||||
if (!["http:", "https:"].includes(parsed.protocol) || parsed.username || parsed.password || parsed.hash) {
|
||||
throw new AppError({ code: "VALID_001", message: "参数校验失败", statusCode: 400, details: { url: "invalid" } });
|
||||
}
|
||||
const allowedOrigin = new URL(resolvePrimaryAppOrigin(config, request)).origin;
|
||||
if (parsed.origin !== allowedOrigin) {
|
||||
throw new AppError({ code: "PERM_001", message: "无权限", statusCode: 403 });
|
||||
}
|
||||
return parsed.href;
|
||||
}
|
||||
|
||||
function parseAllowedReturnHosts(config, request) {
|
||||
const hosts = new Set();
|
||||
const appOrigins = String(config.appOrigin || "")
|
||||
@@ -465,6 +487,31 @@ async function wecomAuthRoutes(fastify) {
|
||||
return sendData(reply, request, resolveWecomEntryConfig(fastify.config));
|
||||
});
|
||||
|
||||
fastify.post("/wecom/js-sdk-signature", { preHandler: fastify.authenticate }, async (request, reply) => {
|
||||
const parsed = JS_SDK_SIGNATURE_BODY_SCHEMA.safeParse(request.body ?? {});
|
||||
if (!parsed.success) {
|
||||
throw new AppError({ code: "VALID_001", message: "参数校验失败", statusCode: 400, details: { url: "invalid" } });
|
||||
}
|
||||
const pageUrl = normalizeJsSdkPageUrl(parsed.data.url, fastify.config, request);
|
||||
const ticketResult = await getWecomJsApiTicket({
|
||||
config: fastify.config,
|
||||
timeoutMs: fastify.config.wecomRequestTimeoutMs,
|
||||
});
|
||||
if (!ticketResult.ok) {
|
||||
request.log.warn({ errorCode: ticketResult.code }, "wecom jsapi ticket unavailable");
|
||||
throw new AppError({
|
||||
code: "SYS_001",
|
||||
message: "企业微信文件预览暂时不可用",
|
||||
statusCode: 503,
|
||||
details: { wecom: "jsapi_ticket_unavailable" },
|
||||
});
|
||||
}
|
||||
return sendData(reply, request, {
|
||||
corpId: String(fastify.config.wecomCorpId ?? ""),
|
||||
...signWecomJsSdkUrl({ ticket: ticketResult.ticket, url: pageUrl }),
|
||||
});
|
||||
});
|
||||
|
||||
fastify.get("/wecom/login", async (request, reply) => {
|
||||
const parsed = QUERY_SCHEMA.safeParse(request.query ?? {});
|
||||
if (!parsed.success) {
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
const crypto = require("node:crypto");
|
||||
|
||||
const { getWecomAppAccessToken } = require("./wecomAccessToken");
|
||||
|
||||
const ticketCache = new Map();
|
||||
|
||||
function toSafeString(value, maxLength = 2000) {
|
||||
if (value == null) return "";
|
||||
return String(value).trim().slice(0, maxLength);
|
||||
}
|
||||
|
||||
function credentialKey(config) {
|
||||
return crypto
|
||||
.createHash("sha256")
|
||||
.update([
|
||||
toSafeString(config?.wecomCorpId, 128),
|
||||
toSafeString(config?.wecomAgentId, 64),
|
||||
toSafeString(config?.wecomAgentSecret, 500),
|
||||
].join(":"))
|
||||
.digest("hex");
|
||||
}
|
||||
|
||||
function sanitizeTicketResponse(payload) {
|
||||
if (!payload || typeof payload !== "object") return null;
|
||||
const result = {};
|
||||
for (const key of ["errcode", "errmsg"]) {
|
||||
if (payload[key] != null) result[key] = payload[key];
|
||||
}
|
||||
return Object.keys(result).length ? result : null;
|
||||
}
|
||||
|
||||
async function getWecomJsApiTicket({ config, timeoutMs, fetchImpl = fetch }) {
|
||||
const cacheKey = credentialKey(config);
|
||||
const cached = ticketCache.get(cacheKey);
|
||||
if (cached?.ticket && cached.expiresAt > Date.now() + 60 * 1000) {
|
||||
return { ok: true, code: "wecom_jsapi_ticket_cached", ticket: cached.ticket };
|
||||
}
|
||||
|
||||
const tokenResult = await getWecomAppAccessToken({ config, timeoutMs, fetchImpl });
|
||||
if (!tokenResult.ok) {
|
||||
return { ok: false, code: tokenResult.code, ticket: null };
|
||||
}
|
||||
|
||||
const timeout = Math.max(100, Number(timeoutMs ?? config?.wecomRequestTimeoutMs ?? 5000));
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), timeout);
|
||||
const url = new URL("https://qyapi.weixin.qq.com/cgi-bin/get_jsapi_ticket");
|
||||
url.searchParams.set("access_token", tokenResult.accessToken);
|
||||
|
||||
try {
|
||||
const response = await fetchImpl(url, { method: "GET", signal: controller.signal });
|
||||
const payload = await response.json().catch(() => null);
|
||||
if (!response.ok) {
|
||||
return {
|
||||
ok: false,
|
||||
code: response.status >= 500 ? "wecom_jsapi_ticket_http_5xx" : "wecom_jsapi_ticket_http_error",
|
||||
response: { status: response.status, body: sanitizeTicketResponse(payload) },
|
||||
ticket: null,
|
||||
};
|
||||
}
|
||||
if (!payload || Number(payload.errcode ?? 0) !== 0 || !payload.ticket) {
|
||||
return {
|
||||
ok: false,
|
||||
code: "wecom_jsapi_ticket_api_error",
|
||||
response: sanitizeTicketResponse(payload),
|
||||
ticket: null,
|
||||
};
|
||||
}
|
||||
|
||||
const expiresIn = Math.max(300, Number(payload.expires_in ?? 7200));
|
||||
const ticket = String(payload.ticket);
|
||||
ticketCache.set(cacheKey, {
|
||||
ticket,
|
||||
expiresAt: Date.now() + Math.max(60, expiresIn - 120) * 1000,
|
||||
});
|
||||
return { ok: true, code: "wecom_jsapi_ticket_fetched", ticket };
|
||||
} catch (error) {
|
||||
return {
|
||||
ok: false,
|
||||
code: error?.name === "AbortError" ? "wecom_jsapi_ticket_timeout" : "wecom_jsapi_ticket_network_error",
|
||||
ticket: null,
|
||||
};
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
|
||||
function signWecomJsSdkUrl({ ticket, url, timestamp = Math.floor(Date.now() / 1000), nonceStr = crypto.randomUUID() }) {
|
||||
const canonical = [
|
||||
`jsapi_ticket=${ticket}`,
|
||||
`noncestr=${nonceStr}`,
|
||||
`timestamp=${timestamp}`,
|
||||
`url=${url}`,
|
||||
].join("&");
|
||||
return {
|
||||
timestamp,
|
||||
nonceStr,
|
||||
signature: crypto.createHash("sha1").update(canonical).digest("hex"),
|
||||
};
|
||||
}
|
||||
|
||||
function clearWecomJsApiTicketCache() {
|
||||
ticketCache.clear();
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
clearWecomJsApiTicketCache,
|
||||
getWecomJsApiTicket,
|
||||
signWecomJsSdkUrl,
|
||||
};
|
||||
@@ -6,33 +6,60 @@ const Fastify = require("fastify");
|
||||
|
||||
const { AppError } = require("../../../src/utils/AppError");
|
||||
const { registerInvoicePoolReadRoutes } = require("../../../src/routes/invoicePool/read");
|
||||
const { signPayload } = require("../../../src/utils/signedUrl");
|
||||
const { signPayload, verifyToken } = require("../../../src/utils/signedUrl");
|
||||
|
||||
const INVOICE_ID = "11111111-1111-4111-8111-111111111111";
|
||||
const ACTOR_ID = "22222222-2222-4222-8222-222222222222";
|
||||
const JWT_SECRET = "synthetic-test-secret";
|
||||
|
||||
async function buildApp() {
|
||||
async function buildApp({ auditFailure = null, invoiceOverrides = {}, employee = null, pendingTask = null } = {}) {
|
||||
const storageDir = await fs.mkdtemp(path.join(os.tmpdir(), "hyb-oa-invoice-file-"));
|
||||
await fs.writeFile(path.join(storageDir, "invoice.pdf"), "%PDF synthetic\n%%EOF\n");
|
||||
const fileContents = "%PDF synthetic\n%%EOF\n";
|
||||
await fs.writeFile(path.join(storageDir, "invoice.pdf"), fileContents);
|
||||
|
||||
const prisma = {
|
||||
invoicePool: {
|
||||
findUnique: vi.fn().mockResolvedValue({
|
||||
id: INVOICE_ID,
|
||||
owner_employee_id: ACTOR_ID,
|
||||
uploaded_by_employee_id: ACTOR_ID,
|
||||
linked_reimbursement_id: null,
|
||||
linked_reimbursement: null,
|
||||
storage_path: "invoice.pdf",
|
||||
mime_type: "application/pdf",
|
||||
original_name: "住宿电子普通发票.pdf",
|
||||
file_size: Buffer.byteLength(fileContents),
|
||||
...invoiceOverrides,
|
||||
}),
|
||||
},
|
||||
workflowTask: {
|
||||
findFirst: vi.fn().mockResolvedValue(pendingTask),
|
||||
},
|
||||
auditLog: {
|
||||
create: vi.fn().mockResolvedValue({ id: "audit-1" }),
|
||||
create: auditFailure
|
||||
? vi.fn().mockRejectedValue(auditFailure)
|
||||
: vi.fn().mockResolvedValue({ id: "audit-1" }),
|
||||
},
|
||||
};
|
||||
|
||||
const app = Fastify();
|
||||
app.decorate("prisma", prisma);
|
||||
app.decorate("config", { jwtSecret: JWT_SECRET, storageDir });
|
||||
app.decorate("authenticate", async () => {});
|
||||
app.decorate("config", {
|
||||
jwtSecret: JWT_SECRET,
|
||||
storageDir,
|
||||
appOrigin: "https://oa.hyinda.com",
|
||||
signedUrlTtlSeconds: 600,
|
||||
});
|
||||
app.decorate("authenticate", async (request) => {
|
||||
if (!request.headers.authorization) {
|
||||
throw new AppError({ code: "AUTH_001", message: "未登录", statusCode: 401 });
|
||||
}
|
||||
request.employee = employee ?? {
|
||||
id: ACTOR_ID,
|
||||
roleCodes: ["finance"],
|
||||
permissionCodes: [],
|
||||
};
|
||||
});
|
||||
app.decorate("hasPermission", () => false);
|
||||
app.decorate("requirePermission", () => async () => {});
|
||||
app.setErrorHandler((error, request, reply) => {
|
||||
@@ -48,7 +75,7 @@ async function buildApp() {
|
||||
});
|
||||
});
|
||||
await app.register(registerInvoicePoolReadRoutes, { prefix: "/api" });
|
||||
return { app, prisma, storageDir };
|
||||
return { app, prisma, storageDir, fileContents };
|
||||
}
|
||||
|
||||
describe("GET /api/invoice-pool/:id/file", () => {
|
||||
@@ -77,4 +104,119 @@ describe("GET /api/invoice-pool/:id/file", () => {
|
||||
await fs.rm(storageDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("fails closed when the file access audit cannot be written", async () => {
|
||||
const { app, storageDir } = await buildApp({ auditFailure: new Error("synthetic audit outage") });
|
||||
const exp = Date.now() + 60_000;
|
||||
const token = signPayload({ invoiceId: INVOICE_ID, actorId: ACTOR_ID, exp }, JWT_SECRET);
|
||||
|
||||
try {
|
||||
const response = await app.inject({
|
||||
method: "GET",
|
||||
url: `/api/invoice-pool/${INVOICE_ID}/file?token=${encodeURIComponent(token)}`,
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(500);
|
||||
expect(response.json().error.code).toBe("SYS_001");
|
||||
expect(response.body).not.toContain("%PDF synthetic");
|
||||
} finally {
|
||||
await app.close();
|
||||
await fs.rm(storageDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("POST /api/invoice-pool/:id/signed-url", () => {
|
||||
it("issues an object-bound URL for at most 120 seconds with preview metadata", async () => {
|
||||
const { app, prisma, storageDir, fileContents } = await buildApp();
|
||||
const issuedBefore = Date.now();
|
||||
|
||||
try {
|
||||
const response = await app.inject({
|
||||
method: "POST",
|
||||
url: `/api/invoice-pool/${INVOICE_ID}/signed-url`,
|
||||
headers: { authorization: "Bearer test", "content-type": "application/json" },
|
||||
payload: {},
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
expect(response.json().data).toEqual(expect.objectContaining({
|
||||
signedUrl: expect.stringMatching(/^https:\/\/oa\.hyinda\.com\/api\/invoice-pool\//),
|
||||
originalName: "住宿电子普通发票.pdf",
|
||||
mimeType: "application/pdf",
|
||||
fileSize: Buffer.byteLength(fileContents),
|
||||
}));
|
||||
const url = new URL(response.json().data.signedUrl);
|
||||
const verified = verifyToken(url.searchParams.get("token"), JWT_SECRET);
|
||||
expect(verified).toEqual(expect.objectContaining({
|
||||
ok: true,
|
||||
payload: expect.objectContaining({ invoiceId: INVOICE_ID, actorId: ACTOR_ID }),
|
||||
}));
|
||||
expect(verified.payload.exp).toBeGreaterThanOrEqual(issuedBefore + 119_000);
|
||||
expect(verified.payload.exp).toBeLessThanOrEqual(Date.now() + 120_000);
|
||||
expect(prisma.auditLog.create).toHaveBeenCalledWith(expect.objectContaining({
|
||||
data: expect.objectContaining({
|
||||
action: "INVOICE_SIGNED_URL_ISSUED",
|
||||
actor_id: ACTOR_ID,
|
||||
target_id: INVOICE_ID,
|
||||
}),
|
||||
}));
|
||||
} finally {
|
||||
await app.close();
|
||||
await fs.rm(storageDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("requires authentication before issuing a signed URL", async () => {
|
||||
const { app, prisma, storageDir } = await buildApp();
|
||||
|
||||
try {
|
||||
const response = await app.inject({
|
||||
method: "POST",
|
||||
url: `/api/invoice-pool/${INVOICE_ID}/signed-url`,
|
||||
payload: {},
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(401);
|
||||
expect(prisma.invoicePool.findUnique).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
await app.close();
|
||||
await fs.rm(storageDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("allows the current pending approver without granting invoice management permission", async () => {
|
||||
const approverId = "33333333-3333-4333-8333-333333333333";
|
||||
const linkedReimbursementId = "44444444-4444-4444-8444-444444444444";
|
||||
const { app, prisma, storageDir } = await buildApp({
|
||||
employee: { id: approverId, roleCodes: ["manager"], permissionCodes: [] },
|
||||
invoiceOverrides: {
|
||||
owner_employee_id: ACTOR_ID,
|
||||
uploaded_by_employee_id: ACTOR_ID,
|
||||
linked_reimbursement_id: linkedReimbursementId,
|
||||
linked_reimbursement: { owner_id: ACTOR_ID, submitter_id: ACTOR_ID },
|
||||
},
|
||||
pendingTask: { id: "synthetic-pending-task" },
|
||||
});
|
||||
|
||||
try {
|
||||
const response = await app.inject({
|
||||
method: "POST",
|
||||
url: `/api/invoice-pool/${INVOICE_ID}/signed-url`,
|
||||
headers: { authorization: "Bearer test", "content-type": "application/json" },
|
||||
payload: {},
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
expect(prisma.workflowTask.findFirst).toHaveBeenCalledWith(expect.objectContaining({
|
||||
where: expect.objectContaining({ status: "pending" }),
|
||||
}));
|
||||
expect(prisma.auditLog.create).toHaveBeenCalledWith(expect.objectContaining({
|
||||
data: expect.objectContaining({ actor_id: approverId, action: "INVOICE_SIGNED_URL_ISSUED" }),
|
||||
}));
|
||||
} finally {
|
||||
await app.close();
|
||||
await fs.rm(storageDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -7,6 +7,10 @@ const multipart = require("@fastify/multipart");
|
||||
|
||||
const { AppError } = require("../../../src/utils/AppError");
|
||||
const reimbursementRoutes = require("../../../src/routes/reimbursements");
|
||||
const { signPayload, verifyToken } = require("../../../src/utils/signedUrl");
|
||||
|
||||
const ACTOR_ID = "cccccccc-cccc-4ccc-8ccc-cccccccccccc";
|
||||
const JWT_SECRET = "synthetic-reimbursement-preview-secret";
|
||||
|
||||
function buildMultipartRequest({ files, fields = {} }) {
|
||||
const boundary = "----codex-reimbursement-attachment-boundary";
|
||||
@@ -49,8 +53,8 @@ async function buildApp({ reimbursementStatus = "draft" } = {}) {
|
||||
reimbursementForm: {
|
||||
findUnique: vi.fn().mockResolvedValue({
|
||||
id: reimbursementId,
|
||||
owner_id: "actor-1",
|
||||
submitter_id: "actor-1",
|
||||
owner_id: ACTOR_ID,
|
||||
submitter_id: ACTOR_ID,
|
||||
status: reimbursementStatus,
|
||||
}),
|
||||
},
|
||||
@@ -76,7 +80,7 @@ async function buildApp({ reimbursementStatus = "draft" } = {}) {
|
||||
storage_path: data.storage_path,
|
||||
created_at: new Date("2026-05-08T08:00:00.000Z"),
|
||||
uploader: {
|
||||
id: "actor-1",
|
||||
id: ACTOR_ID,
|
||||
employee_no: "E001",
|
||||
name: "测试用户",
|
||||
},
|
||||
@@ -109,13 +113,14 @@ async function buildApp({ reimbursementStatus = "draft" } = {}) {
|
||||
storage_path: item.storage_path,
|
||||
original_name: item.original_name,
|
||||
mime_type: item.mime_type,
|
||||
file_size: item.file_size,
|
||||
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",
|
||||
owner_id: ACTOR_ID,
|
||||
submitter_id: ACTOR_ID,
|
||||
},
|
||||
};
|
||||
}),
|
||||
@@ -126,8 +131,8 @@ async function buildApp({ reimbursementStatus = "draft" } = {}) {
|
||||
$queryRaw: vi.fn().mockResolvedValue([
|
||||
{
|
||||
id: reimbursementId,
|
||||
owner_id: "actor-1",
|
||||
submitter_id: "actor-1",
|
||||
owner_id: ACTOR_ID,
|
||||
submitter_id: ACTOR_ID,
|
||||
status: reimbursementStatus,
|
||||
},
|
||||
]),
|
||||
@@ -140,13 +145,16 @@ async function buildApp({ reimbursementStatus = "draft" } = {}) {
|
||||
appTimezone: "Asia/Shanghai",
|
||||
storageDir: tempDir,
|
||||
maxUploadSizeBytes: 5 * 1024 * 1024,
|
||||
jwtSecret: JWT_SECRET,
|
||||
appOrigin: "https://oa.hyinda.com",
|
||||
signedUrlTtlSeconds: 600,
|
||||
});
|
||||
app.decorate("authenticate", async (request) => {
|
||||
if (!request.headers.authorization) {
|
||||
throw new AppError({ code: "AUTH_001", message: "未登录", statusCode: 401 });
|
||||
}
|
||||
request.employee = {
|
||||
id: "actor-1",
|
||||
id: ACTOR_ID,
|
||||
roleCodes: ["employee"],
|
||||
permissionCodes: ["expense.edit_own", "expense.view_own"],
|
||||
};
|
||||
@@ -464,4 +472,214 @@ describe("reimbursement attachment routes", () => {
|
||||
await fs.rm(tempDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("issues and consumes a short-lived object-bound WeCom preview URL", async () => {
|
||||
const { app, prisma, tempDir, reimbursementId } = await buildApp();
|
||||
|
||||
try {
|
||||
const requestPayload = buildMultipartRequest({
|
||||
files: [{ filename: "evidence.pdf", contentType: "application/pdf", content: "%PDF preview" }],
|
||||
});
|
||||
const uploadResponse = await app.inject({
|
||||
method: "POST",
|
||||
url: `/api/reimbursements/${reimbursementId}/attachments`,
|
||||
headers: { authorization: "Bearer test", ...requestPayload.headers },
|
||||
payload: requestPayload.payload,
|
||||
});
|
||||
const attachment = uploadResponse.json().data.items[0];
|
||||
const issuedBefore = Date.now();
|
||||
|
||||
const previewResponse = await app.inject({
|
||||
method: "POST",
|
||||
url: `/api/reimbursements/${reimbursementId}/attachments/${attachment.id}/preview-url`,
|
||||
headers: { authorization: "Bearer test", "content-type": "application/json" },
|
||||
payload: {},
|
||||
});
|
||||
|
||||
expect(previewResponse.statusCode).toBe(200);
|
||||
expect(previewResponse.json().data).toEqual(expect.objectContaining({
|
||||
signedUrl: expect.stringMatching(/^https:\/\/oa\.hyinda\.com\/api\/reimbursements\//),
|
||||
originalName: "evidence.pdf",
|
||||
mimeType: "application/pdf",
|
||||
fileSize: Buffer.byteLength("%PDF preview"),
|
||||
}));
|
||||
const signedUrl = new URL(previewResponse.json().data.signedUrl);
|
||||
const verified = verifyToken(signedUrl.searchParams.get("previewToken"), JWT_SECRET);
|
||||
expect(verified).toEqual(expect.objectContaining({
|
||||
ok: true,
|
||||
payload: expect.objectContaining({
|
||||
purpose: "wecom_preview",
|
||||
reimbursementId,
|
||||
attachmentId: attachment.id,
|
||||
actorId: ACTOR_ID,
|
||||
}),
|
||||
}));
|
||||
expect(verified.payload.exp).toBeGreaterThanOrEqual(issuedBefore + 119_000);
|
||||
expect(verified.payload.exp).toBeLessThanOrEqual(Date.now() + 120_000);
|
||||
expect(prisma.auditLog.create).toHaveBeenCalledWith(expect.objectContaining({
|
||||
data: expect.objectContaining({ action: "REIMBURSEMENT_ATTACHMENT_PREVIEW_URL_ISSUED" }),
|
||||
}));
|
||||
|
||||
const fileResponse = await app.inject({
|
||||
method: "GET",
|
||||
url: `${signedUrl.pathname}${signedUrl.search}`,
|
||||
});
|
||||
expect(fileResponse.statusCode).toBe(200);
|
||||
expect(fileResponse.body).toBe("%PDF preview");
|
||||
expect(fileResponse.headers["cache-control"]).toBe("private, no-store");
|
||||
expect(fileResponse.headers["referrer-policy"]).toBe("no-referrer");
|
||||
expect(prisma.auditLog.create).toHaveBeenCalledWith(expect.objectContaining({
|
||||
data: expect.objectContaining({
|
||||
action: "REIMBURSEMENT_ATTACHMENT_DOWNLOADED",
|
||||
actor_id: ACTOR_ID,
|
||||
extra: expect.objectContaining({ reason: "企业微信在线预览" }),
|
||||
}),
|
||||
}));
|
||||
} finally {
|
||||
await app.close();
|
||||
await fs.rm(tempDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects tampered, expired, and object-mismatched preview grants", async () => {
|
||||
const { app, tempDir, reimbursementId } = await buildApp();
|
||||
|
||||
try {
|
||||
const requestPayload = buildMultipartRequest({
|
||||
files: [{ filename: "evidence.pdf", contentType: "application/pdf", content: "%PDF preview" }],
|
||||
});
|
||||
const uploadResponse = await app.inject({
|
||||
method: "POST",
|
||||
url: `/api/reimbursements/${reimbursementId}/attachments`,
|
||||
headers: { authorization: "Bearer test", ...requestPayload.headers },
|
||||
payload: requestPayload.payload,
|
||||
});
|
||||
const attachmentId = uploadResponse.json().data.items[0].id;
|
||||
const basePayload = {
|
||||
purpose: "wecom_preview",
|
||||
reimbursementId,
|
||||
attachmentId,
|
||||
actorId: ACTOR_ID,
|
||||
};
|
||||
const validToken = signPayload({ ...basePayload, exp: Date.now() + 60_000 }, JWT_SECRET);
|
||||
const tamperedToken = `${validToken.slice(0, -1)}${validToken.endsWith("a") ? "b" : "a"}`;
|
||||
const expiredToken = signPayload({ ...basePayload, exp: Date.now() - 1 }, JWT_SECRET);
|
||||
const mismatchedToken = signPayload({
|
||||
...basePayload,
|
||||
reimbursementId: "dddddddd-dddd-4ddd-8ddd-dddddddddddd",
|
||||
exp: Date.now() + 60_000,
|
||||
}, JWT_SECRET);
|
||||
|
||||
for (const token of [tamperedToken, expiredToken, mismatchedToken]) {
|
||||
const response = await app.inject({
|
||||
method: "GET",
|
||||
url: `/api/reimbursements/${reimbursementId}/attachments/${attachmentId}/file?previewToken=${encodeURIComponent(token)}`,
|
||||
});
|
||||
expect(response.statusCode).toBe(403);
|
||||
expect(response.json().error.code).toBe("PERM_001");
|
||||
}
|
||||
} finally {
|
||||
await app.close();
|
||||
await fs.rm(tempDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("requires login and any configured download reason before issuing a preview URL", async () => {
|
||||
const { app, prisma, tempDir, reimbursementId, createdAttachments } = await buildApp();
|
||||
|
||||
try {
|
||||
const requestPayload = buildMultipartRequest({
|
||||
files: [{ filename: "evidence.pdf", contentType: "application/pdf", content: "%PDF preview" }],
|
||||
});
|
||||
const uploadResponse = await app.inject({
|
||||
method: "POST",
|
||||
url: `/api/reimbursements/${reimbursementId}/attachments`,
|
||||
headers: { authorization: "Bearer test", ...requestPayload.headers },
|
||||
payload: requestPayload.payload,
|
||||
});
|
||||
const attachmentId = uploadResponse.json().data.items[0].id;
|
||||
createdAttachments[0].download_policy = "reason_required";
|
||||
prisma.auditLog.create.mockClear();
|
||||
|
||||
const unauthenticated = await app.inject({
|
||||
method: "POST",
|
||||
url: `/api/reimbursements/${reimbursementId}/attachments/${attachmentId}/preview-url`,
|
||||
payload: {},
|
||||
});
|
||||
const missingReason = await app.inject({
|
||||
method: "POST",
|
||||
url: `/api/reimbursements/${reimbursementId}/attachments/${attachmentId}/preview-url`,
|
||||
headers: { authorization: "Bearer test", "content-type": "application/json" },
|
||||
payload: {},
|
||||
});
|
||||
|
||||
expect(unauthenticated.statusCode).toBe(401);
|
||||
expect(missingReason.statusCode).toBe(400);
|
||||
expect(missingReason.json().error.details).toEqual({ reason: "required" });
|
||||
expect(prisma.auditLog.create).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
await app.close();
|
||||
await fs.rm(tempDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("fails closed before preview bytes when the access audit cannot be written", async () => {
|
||||
const { app, prisma, tempDir, reimbursementId } = await buildApp();
|
||||
|
||||
try {
|
||||
const requestPayload = buildMultipartRequest({
|
||||
files: [{ filename: "evidence.pdf", contentType: "application/pdf", content: "%PDF preview" }],
|
||||
});
|
||||
const uploadResponse = await app.inject({
|
||||
method: "POST",
|
||||
url: `/api/reimbursements/${reimbursementId}/attachments`,
|
||||
headers: { authorization: "Bearer test", ...requestPayload.headers },
|
||||
payload: requestPayload.payload,
|
||||
});
|
||||
const attachmentId = uploadResponse.json().data.items[0].id;
|
||||
const previewToken = signPayload({
|
||||
purpose: "wecom_preview",
|
||||
reimbursementId,
|
||||
attachmentId,
|
||||
actorId: ACTOR_ID,
|
||||
exp: Date.now() + 60_000,
|
||||
}, JWT_SECRET);
|
||||
prisma.auditLog.create.mockRejectedValueOnce(new Error("synthetic audit outage"));
|
||||
|
||||
const response = await app.inject({
|
||||
method: "GET",
|
||||
url: `/api/reimbursements/${reimbursementId}/attachments/${attachmentId}/file?previewToken=${encodeURIComponent(previewToken)}`,
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(500);
|
||||
expect(response.json().error.code).toBe("SYS_001");
|
||||
expect(response.body).not.toContain("%PDF preview");
|
||||
} finally {
|
||||
await app.close();
|
||||
await fs.rm(tempDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects missing or invalid authorization before looking up the attachment", async () => {
|
||||
const { app, prisma, tempDir, reimbursementId } = await buildApp();
|
||||
const attachmentId = "bbbbbbbb-bbbb-4bbb-8bbb-000000000001";
|
||||
|
||||
try {
|
||||
const unauthenticated = await app.inject({
|
||||
method: "GET",
|
||||
url: `/api/reimbursements/${reimbursementId}/attachments/${attachmentId}/file`,
|
||||
});
|
||||
const invalidGrant = await app.inject({
|
||||
method: "GET",
|
||||
url: `/api/reimbursements/${reimbursementId}/attachments/${attachmentId}/file?previewToken=invalid`,
|
||||
});
|
||||
|
||||
expect(unauthenticated.statusCode).toBe(401);
|
||||
expect(invalidGrant.statusCode).toBe(403);
|
||||
expect(prisma.reimbursementAttachment.findFirst).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
await app.close();
|
||||
await fs.rm(tempDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -4,6 +4,8 @@ const fastifyCookie = require("@fastify/cookie");
|
||||
const { AppError } = require("../../../src/utils/AppError");
|
||||
const wecomAuthRoutes = require("../../../src/routes/wecomAuth");
|
||||
const { signPayload, verifyToken } = require("../../../src/utils/signedUrl");
|
||||
const { clearWecomAppAccessTokenCache } = require("../../../src/services/integrations/wecomAccessToken");
|
||||
const { clearWecomJsApiTicketCache } = require("../../../src/services/integrations/wecomJsSdk");
|
||||
|
||||
const NOW = new Date("2026-03-22T11:00:00.000Z");
|
||||
const EMPLOYEE_ID = "11111111-1111-4111-8111-111111111111";
|
||||
@@ -103,6 +105,8 @@ function mockWecomFetchWithPayload(payload) {
|
||||
|
||||
afterEach(() => {
|
||||
global.fetch = originalFetch;
|
||||
clearWecomAppAccessTokenCache();
|
||||
clearWecomJsApiTicketCache();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
@@ -1069,4 +1073,115 @@ describe("wecom auth routes R27", () => {
|
||||
|
||||
await app.close();
|
||||
});
|
||||
|
||||
it("signs a same-origin page URL without exposing WeCom credentials", async () => {
|
||||
global.fetch = vi.fn()
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => ({ errcode: 0, access_token: "synthetic-access-token", expires_in: 7200 }),
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => ({ errcode: 0, ticket: "synthetic-jsapi-ticket", expires_in: 7200 }),
|
||||
});
|
||||
const app = await buildApp();
|
||||
|
||||
const response = await app.inject({
|
||||
method: "POST",
|
||||
url: "/api/auth/wecom/js-sdk-signature",
|
||||
headers: { authorization: "Bearer test-token", "content-type": "application/json" },
|
||||
payload: { url: "https://oa.hyinda.com/mobile/reimbursements/test?source=wecom" },
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
expect(response.json().data).toEqual({
|
||||
corpId: "wx-test-corp",
|
||||
timestamp: expect.any(Number),
|
||||
nonceStr: expect.any(String),
|
||||
signature: expect.stringMatching(/^[a-f0-9]{40}$/),
|
||||
});
|
||||
const serialized = JSON.stringify(response.json()).toLowerCase();
|
||||
expect(serialized).not.toContain("synthetic-access-token");
|
||||
expect(serialized).not.toContain("synthetic-jsapi-ticket");
|
||||
expect(serialized).not.toContain("wecom-secret");
|
||||
expect(global.fetch).toHaveBeenCalledTimes(2);
|
||||
|
||||
await app.close();
|
||||
});
|
||||
|
||||
it("rejects cross-origin and credential-bearing JS-SDK signing URLs before provider access", async () => {
|
||||
global.fetch = vi.fn();
|
||||
const app = await buildApp();
|
||||
|
||||
const crossOrigin = await app.inject({
|
||||
method: "POST",
|
||||
url: "/api/auth/wecom/js-sdk-signature",
|
||||
headers: { authorization: "Bearer test-token", "content-type": "application/json" },
|
||||
payload: { url: "https://evil.example/mobile" },
|
||||
});
|
||||
const credentialUrl = await app.inject({
|
||||
method: "POST",
|
||||
url: "/api/auth/wecom/js-sdk-signature",
|
||||
headers: { authorization: "Bearer test-token", "content-type": "application/json" },
|
||||
payload: { url: "https://user:[email protected]/mobile" },
|
||||
});
|
||||
const fragmentUrl = await app.inject({
|
||||
method: "POST",
|
||||
url: "/api/auth/wecom/js-sdk-signature",
|
||||
headers: { authorization: "Bearer test-token", "content-type": "application/json" },
|
||||
payload: { url: "https://oa.hyinda.com/mobile#detail" },
|
||||
});
|
||||
|
||||
expect(crossOrigin.statusCode).toBe(403);
|
||||
expect(credentialUrl.statusCode).toBe(400);
|
||||
expect(fragmentUrl.statusCode).toBe(400);
|
||||
expect(global.fetch).not.toHaveBeenCalled();
|
||||
|
||||
await app.close();
|
||||
});
|
||||
|
||||
it("returns a stable redacted error when the JSAPI ticket provider fails", async () => {
|
||||
global.fetch = vi.fn()
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => ({ errcode: 0, access_token: "synthetic-access-token", expires_in: 7200 }),
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => ({ errcode: 40001, errmsg: "provider-secret-detail" }),
|
||||
});
|
||||
const app = await buildApp();
|
||||
|
||||
const response = await app.inject({
|
||||
method: "POST",
|
||||
url: "/api/auth/wecom/js-sdk-signature",
|
||||
headers: { authorization: "Bearer test-token", "content-type": "application/json" },
|
||||
payload: { url: "https://oa.hyinda.com/mobile" },
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(503);
|
||||
expect(response.json().error.details).toEqual({ wecom: "jsapi_ticket_unavailable" });
|
||||
expect(JSON.stringify(response.json())).not.toContain("provider-secret-detail");
|
||||
expect(JSON.stringify(response.json())).not.toContain("synthetic-access-token");
|
||||
|
||||
await app.close();
|
||||
});
|
||||
|
||||
it("fails closed without provider access when JS-SDK credentials are incomplete", async () => {
|
||||
global.fetch = vi.fn();
|
||||
const app = await buildApp({ config: { wecomAgentSecret: "" } });
|
||||
|
||||
const response = await app.inject({
|
||||
method: "POST",
|
||||
url: "/api/auth/wecom/js-sdk-signature",
|
||||
headers: { authorization: "Bearer test-token", "content-type": "application/json" },
|
||||
payload: { url: "https://oa.hyinda.com/mobile" },
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(503);
|
||||
expect(response.json().error.details).toEqual({ wecom: "jsapi_ticket_unavailable" });
|
||||
expect(global.fetch).not.toHaveBeenCalled();
|
||||
|
||||
await app.close();
|
||||
});
|
||||
});
|
||||
|
||||
在新工单中引用
屏蔽一个用户