比较提交
2
次代码提交
| 作者 | SHA1 | 提交日期 | |
|---|---|---|---|
|
|
8b376f4135 | ||
|
|
20b20b100e |
@@ -116,6 +116,25 @@
|
||||
- [x] 用合成文件与 mock JS bridge 运行后端聚焦测试、前端 typecheck/build 和 PC/移动 Playwright 一致性回归。
|
||||
- [x] 运行 Trellis 治理、流程/文档、敏感扫描、`git diff --check`,并对新增短时授权边界做只读 review;不在本步骤部署生产。
|
||||
|
||||
## 6.6 原生预览生产发布与运维对齐
|
||||
|
||||
- [x] 将提交 `410d8cd` 推送后以 `SYNC_ENV=0` 发布;未覆盖生产 `.env`,62 个 migration 无待执行项,公网 `/ready` 的 12 位构建标识匹配。
|
||||
- [x] 如实记录两次发布尝试:第一次使用 runbook 示例发布目录和普通运维入口,来源/环境合同通过,但 rsync 因目录权限在同步前失败,生产未变化;随后从活动 Compose 标签解析实际发布根目录,复用既有受控 root 运维入口成功发布,未新增 sudo 或修改目录权限。
|
||||
- [x] external deps、runtime smoke 与公网 bundle 扫描通过;公网包包含 `previewFile`、同源 JS-SDK 签名接口和附件短时预览入口,未登录请求签名接口返回 401。
|
||||
- [x] 发布后从既有 unit 内部复用受控合同参数和运行时间,仅把 automatic backup 三项与 WARCH3 的构建标识刷新为 `410d8cd`;4 个 service 均匹配活动构建,4 个 timer 均为 enabled/active。
|
||||
- [x] 手工触发 automatic backup daily 和源端 watchdog,二者均为 `success/0`;daily 成功以异机接收端完成文件、manifest、哈希校验和原子 finalize 为既有合同。
|
||||
- [x] 记录非阻断依赖 finding:本次镜像构建审计报告 client 18 个风险(10 high),server 32 个风险(20 high、2 critical);未在发布窗口执行自动升级,留待独立依赖升级和回归任务。
|
||||
- [x] 记录证据最小化 finding:既有 external/runtime smoke 输出仍包含不必要的配置标识和测试管理员联系方式;本任务不复述这些值,后续应独立收敛为状态与计数输出。
|
||||
- [ ] 请用户在企微自建应用刷新后先用无业务数据 PDF 验证原生预览,再由马建勇查看当前真实报销的发票与附件;材料真实可见前不得完成主管审批。
|
||||
|
||||
## 6.7 移动报销查看范围对齐
|
||||
|
||||
- [x] 在 `MobileReimbursementsPage` 读取 `expense.view_all`,仅对有权用户显示“我的 / 全部”范围切换;复用现有 Tabs/按钮样式,触控高度为 48px。
|
||||
- [x] 范围默认和无权限回退均为 `own`;切换 `all` 时写入 URL 查询参数并纳入 React Query key,调用现有 `fetchReimbursements({ scope })`,不改后端或扩大权限。
|
||||
- [x] 列表到详情的 `state.from` 保留当前 status 和 scope;普通员工不渲染“全部”入口,“全部”范围不显示仅限本人草稿的删除动作。
|
||||
- [x] 增加聚焦 Playwright:管理员默认本人、切换全部后看到他人单据并进入详情;普通员工即使手工带 `scope=all` 也不显示范围切换且请求保持 `scope=own`。
|
||||
- [x] 运行前端 typecheck/build、聚焦 Playwright、task/governance、流程/文档、敏感扫描和 `git diff --check`;部署另行批准。
|
||||
|
||||
## 7. 完成条件
|
||||
|
||||
- [ ] PRD 全部验收项有脱敏证据或明确由用户完成的真实业务验收结果。
|
||||
@@ -165,3 +184,8 @@
|
||||
- 短时授权只读 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。本步骤未部署生产,真机和真实材料验收仍待单独批准。
|
||||
- 原生预览生产发布:`410d8cd71b1f` 已部署,`SYNC_ENV=0`,62 个 migration 无待执行项;`/ready`、external deps、runtime smoke 与公网 bundle 扫描通过,未登录 JS-SDK 签名请求为 401。第一次尝试在同步前因示例目录权限失败且生产未变化,第二次通过活动 Compose 标签解析实际发布根目录并复用既有受控 root 运维入口成功。
|
||||
- 本次发布后运维身份:automatic backup 三项与 WARCH3 共 4 个 service 均匹配 `410d8cd`,4 个 timer 均 enabled/active;新 automatic backup daily 与源端 watchdog 均为 `success/0`。
|
||||
- 新增 finding:镜像构建审计为 client 18 个风险(10 high)、server 32 个风险(20 high、2 critical),未执行自动修复;既有部署 smoke 还会输出不必要的配置标识和测试管理员联系方式,后续需分别建立依赖升级回归和证据最小化收敛项。
|
||||
- 外部验收边界:生产已部署但企微真机中的无业务 PDF 原生预览、马建勇对真实发票与附件的可见性仍待人工验收;在材料真实可见前不得完成主管审批。
|
||||
- 移动范围对齐:管理员移动报销页新增“我的 / 全部”,默认 `own`,选择“全部”后请求现有 `scope=all` 并把 status/scope 带入详情回跳来源;普通员工即使 URL 带 `scope=all` 仍请求 `own` 且看不到切换。首轮 Playwright 因未显式指定本地 Vite 地址而在登录前连接失败;指定 `E2E_BASE_URL` 后功能执行,首次严格 44px 断言测得浏览器子像素值略低于 44,控件提升为 48px 后最终聚焦 2/2、同文件完整回归 5/5 通过。375px 竖屏和 667×375 横屏无横向溢出,typecheck/build 通过。
|
||||
|
||||
@@ -41,6 +41,7 @@
|
||||
- PC 与移动报销详情应按 KISS 原则收敛为“概况 → 明细 → 发票 → 附件 → 审批记录”的业务主线:删除重复状态、重复标题和面向只读用户的操作教学;没有实际内容或动作的辅助区块不渲染,同时保留编辑态必要控件、风险提示和审计信息。
|
||||
- 生产真机验收确认企微 WebView 可继续运行部署前的旧前端包:公网资源已包含“查看完整报销单”,但存活页面仍只显示报销日期。页面从后台恢复到前台时必须用现有前后端构建标识做一次轻量比对;不一致时只显示用户可控的“发现新版本,点击刷新”,点击后清理旧 PWA 缓存并重载,不自动打断当前页面操作,不扩展为完整更新管理系统。
|
||||
- 真实审批继续暴露材料查看阻断:PC 能打开普通附件,但 PC 与移动报销详情均没有发票原件入口,企微 WebView 中异步 `window.open(blob:)` 只下载附件而不能预览。企业微信自建应用内必须优先使用官方 `ww.previewFile` / `wx.previewFile` 原生能力查看 PDF;普通 PC 浏览器保留浏览器预览,两端保持“查看原件”的业务能力一致。
|
||||
- 生产验收发现移动报销列表固定请求 `scope=own`,导致已有 `expense.view_all` 的管理员无法在企微工作台找到杨俊的报销单并代为验证原生预览。移动端必须与 PC 的范围能力对齐:仅对 `expense.view_all` 用户显示“我的 / 全部”切换,默认仍为“我的”,选择“全部”时复用现有 `scope=all` API;普通员工不显示范围切换,不修改后端权限模型。
|
||||
- 企业微信原生预览使用准确的文件字节数、带扩展名的文件名和短时签名 URL。原生层不会携带 OA Bearer token 或可靠复用 WebView Cookie,因此附件可以在当前任务内增加仅用于 `previewFile` 的短时 bearer URL 例外;该 URL 必须在签发前执行当前对象级授权和下载理由检查,绑定文件与访问人、短时过期、不得包含长期凭据,并在实际取件时写审计。
|
||||
|
||||
## Scope
|
||||
@@ -74,6 +75,7 @@
|
||||
- [x] 已登录页面从后台恢复到前台且前后端构建不一致时显示明确刷新提示;构建一致、检查失败或开发构建时不误提示,刷新动作复用缓存清理后重载能力。
|
||||
- [x] 移动企微报销详情中的发票和 PDF/图片附件均提供“查看原件”,通过官方 JS-SDK 原生预览打开;调用参数包含准确 `size` 和带扩展名的 `name`,不依赖异步弹窗或 WebView 原生 PDF 渲染。
|
||||
- [x] PC 报销详情中的发票和附件提供对应的“查看原件”,使用短时签名 URL 或现有认证下载能力;两端审批人均只能查看当前报销绑定材料,不能编辑、解绑、删除或获得票据池管理权限。
|
||||
- [x] 移动报销列表仅对具备 `expense.view_all` 的用户显示“我的 / 全部”切换,默认“我的”;切换“全部”后能通过现有列表进入他人报销详情,普通员工始终只能查看本人列表。
|
||||
- [x] JS-SDK 签名接口只接受本站允许 origin 的无 fragment 页面 URL,不向客户端、日志或审计暴露 JSAPI ticket、企微 Secret 或 access token;签名失败和非企微环境安全降级。
|
||||
- [ ] 发票/附件预览 URL 的过期、篡改、对象不匹配、缺少下载理由和未授权访问均被拒绝;实际文件响应保留审计,真实材料可见前不允许完成主管审批。
|
||||
|
||||
@@ -98,3 +100,4 @@
|
||||
- 用合成 UTC 时间在聚焦 Playwright 中验证附件时间转换为上海时间,并验证 PC/移动 KISS 信息层级、编辑态关键入口和无动作只读态;不读取生产真实报销内容。
|
||||
- 用合成 `/ready` 响应验证前台恢复时的构建不一致提示、同版本静默和检查失败静默;不依赖生产真机数据。
|
||||
- 用合成企微 ticket、签名 URL 和 PDF/图片元数据验证:JS-SDK URL 签名、origin allowlist、原生 `previewFile` 参数、PC fallback、审批人对象级只读授权、附件理由门禁、短时 token 过期/篡改拒绝和实际查看审计;不得使用真实发票或附件。
|
||||
- 用合成管理员与普通员工权限验证移动报销范围:管理员默认请求 `scope=own`,切换后请求 `scope=all` 并显示他人报销;普通员工不显示“全部”且始终请求 `scope=own`。
|
||||
|
||||
@@ -160,17 +160,24 @@ export function MobileReimbursementsPage() {
|
||||
const createIntentHandledRef = useRef(false);
|
||||
const canCreateExpense = hasPermission(permissions, "expense.create");
|
||||
const canDeleteExpense = hasPermission(permissions, "expense.delete_own");
|
||||
const canViewAllExpense = hasPermission(permissions, "expense.view_all");
|
||||
const scope: "own" | "all" = canViewAllExpense && searchParams.get("scope") === "all" ? "all" : "own";
|
||||
const currentMobileSource = useMemo(() => {
|
||||
const params = new URLSearchParams(location.search);
|
||||
params.delete("intent");
|
||||
params.set("status", status);
|
||||
if (canViewAllExpense) {
|
||||
params.set("scope", scope);
|
||||
} else {
|
||||
params.delete("scope");
|
||||
}
|
||||
const query = params.toString();
|
||||
return `${location.pathname}${query ? `?${query}` : ""}`;
|
||||
}, [location.pathname, location.search, status]);
|
||||
}, [canViewAllExpense, location.pathname, location.search, scope, status]);
|
||||
|
||||
const listQuery = useQuery({
|
||||
queryKey: ["mobile", "reimbursements", status],
|
||||
queryFn: () => fetchReimbursements({ status, page: 1, pageSize: 20, scope: "own" }),
|
||||
queryKey: ["mobile", "reimbursements", status, scope],
|
||||
queryFn: () => fetchReimbursements({ status, page: 1, pageSize: 20, scope }),
|
||||
});
|
||||
|
||||
const createMutation = useMutation({
|
||||
@@ -203,6 +210,16 @@ export function MobileReimbursementsPage() {
|
||||
setSearchParams(nextParams, { replace: true });
|
||||
}
|
||||
|
||||
function handleScopeChange(value: string) {
|
||||
if (!canViewAllExpense || (value !== "own" && value !== "all")) {
|
||||
return;
|
||||
}
|
||||
const nextParams = new URLSearchParams(searchParams);
|
||||
nextParams.delete("intent");
|
||||
nextParams.set("scope", value);
|
||||
setSearchParams(nextParams, { replace: true });
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (searchParams.get("intent") !== "create" || !canCreateExpense || createIntentHandledRef.current) {
|
||||
return;
|
||||
@@ -293,6 +310,21 @@ export function MobileReimbursementsPage() {
|
||||
</div>
|
||||
</div>
|
||||
<div className="mobile-command-surface">
|
||||
{canViewAllExpense ? (
|
||||
<div className="space-y-2">
|
||||
<div className="text-xs font-medium text-zinc-500">查看范围</div>
|
||||
<Tabs value={scope} onValueChange={handleScopeChange}>
|
||||
<TabsList className="grid h-auto min-h-12 w-full grid-cols-2" aria-label="报销查看范围">
|
||||
<TabsTrigger className="min-h-12" data-testid="mobile-reimbursements-scope-own" value="own">
|
||||
我的
|
||||
</TabsTrigger>
|
||||
<TabsTrigger className="min-h-12" data-testid="mobile-reimbursements-scope-all" value="all">
|
||||
全部
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
</Tabs>
|
||||
</div>
|
||||
) : null}
|
||||
<label className="text-xs font-medium text-zinc-500" htmlFor="mobile-reimbursements-current-search">
|
||||
当前列表搜索
|
||||
</label>
|
||||
@@ -326,7 +358,7 @@ export function MobileReimbursementsPage() {
|
||||
) : null}
|
||||
{visibleItems.map((item) => (
|
||||
<ReimbursementCard
|
||||
canDeleteDraft={canDeleteExpense && item.status === "draft"}
|
||||
canDeleteDraft={canDeleteExpense && scope === "own" && item.status === "draft"}
|
||||
currentMobileSource={currentMobileSource}
|
||||
isDeleting={deleteMutation.isPending}
|
||||
item={item}
|
||||
|
||||
@@ -371,15 +371,105 @@ test.describe("R102 reimbursement main flow", () => {
|
||||
|
||||
test("移动报销卡片显示业务编号", async ({ page }) => {
|
||||
await page.setViewportSize({ width: 390, height: 844 });
|
||||
await page.goto("/mobile/reimbursements");
|
||||
const listRequest = page.waitForRequest((request) => {
|
||||
const url = new URL(request.url());
|
||||
return request.method() === "GET" && url.pathname === "/api/reimbursements";
|
||||
});
|
||||
await page.goto("/mobile/reimbursements?scope=all");
|
||||
|
||||
const requestedUrl = new URL((await listRequest).url());
|
||||
expect(requestedUrl.searchParams.get("scope")).toBe("own");
|
||||
await expect(page.getByRole("heading", { name: "移动报销", exact: true })).toBeVisible();
|
||||
await expect(page.getByTestId("mobile-reimbursements-scope-own")).toHaveCount(0);
|
||||
await expect(page.getByTestId("mobile-reimbursements-scope-all")).toHaveCount(0);
|
||||
await expect(page.getByTestId("mobile-reimbursement-card")).toBeVisible();
|
||||
await expect(page.getByText(formNumber)).toBeVisible();
|
||||
await expect(page.getByText(reimbursementId)).toHaveCount(0);
|
||||
await expect(page.locator("table:visible")).toHaveCount(0);
|
||||
});
|
||||
|
||||
test("移动管理员默认查看本人并可切换全部报销", async ({ page }) => {
|
||||
const adminMe = {
|
||||
...me,
|
||||
id: "admin-r102",
|
||||
employeeNo: "A102",
|
||||
name: "测试管理员",
|
||||
roleCodes: ["admin"],
|
||||
};
|
||||
const otherReimbursementId = "44444444-4444-4444-8444-444444444444";
|
||||
const requestedScopes: Array<string | null> = [];
|
||||
|
||||
await page.route("**/api/me", async (route) => {
|
||||
await fulfillJson(route, adminMe);
|
||||
});
|
||||
await page.route((url) => url.pathname === "/api/reimbursements", async (route) => {
|
||||
const request = route.request();
|
||||
if (request.method() !== "GET") {
|
||||
await route.fallback();
|
||||
return;
|
||||
}
|
||||
const url = new URL(request.url());
|
||||
const scope = url.searchParams.get("scope");
|
||||
requestedScopes.push(scope);
|
||||
const ownItem = {
|
||||
...reimbursementListItem(),
|
||||
status: "reviewing",
|
||||
owner: { id: adminMe.id, employeeNo: adminMe.employeeNo, name: adminMe.name },
|
||||
submitter: { id: adminMe.id, employeeNo: adminMe.employeeNo, name: adminMe.name },
|
||||
};
|
||||
const otherItem = {
|
||||
...ownItem,
|
||||
id: otherReimbursementId,
|
||||
formNumber: "BX-20260731-0002",
|
||||
owner: { id: "employee-other-r102", employeeNo: "E103", name: "其他员工" },
|
||||
submitter: { id: "employee-other-r102", employeeNo: "E103", name: "其他员工" },
|
||||
};
|
||||
const items = scope === "all" ? [ownItem, otherItem] : [ownItem];
|
||||
await fulfillJson(route, { page: 1, pageSize: 20, total: items.length, scope: scope ?? "own", items });
|
||||
});
|
||||
|
||||
await page.reload();
|
||||
await expect(page).toHaveURL(/\/portal/);
|
||||
await page.setViewportSize({ width: 375, height: 812 });
|
||||
await page.goto("/mobile/reimbursements?status=reviewing");
|
||||
|
||||
await expect(page.getByTestId("mobile-reimbursements-scope-own")).toHaveAttribute("data-state", "active");
|
||||
await expect(page.getByText("其他员工")).toHaveCount(0);
|
||||
expect(requestedScopes.at(-1)).toBe("own");
|
||||
|
||||
const ownBounds = await page.getByTestId("mobile-reimbursements-scope-own").boundingBox();
|
||||
const allBounds = await page.getByTestId("mobile-reimbursements-scope-all").boundingBox();
|
||||
expect(ownBounds?.height ?? 0).toBeGreaterThanOrEqual(44);
|
||||
expect(allBounds?.height ?? 0).toBeGreaterThanOrEqual(44);
|
||||
|
||||
const allResponse = page.waitForResponse((response) => {
|
||||
const url = new URL(response.url());
|
||||
return response.ok() && url.pathname === "/api/reimbursements" && url.searchParams.get("scope") === "all";
|
||||
});
|
||||
await page.getByTestId("mobile-reimbursements-scope-all").click();
|
||||
await allResponse;
|
||||
|
||||
await expect(page).toHaveURL(/scope=all/);
|
||||
await expect(page.getByTestId("mobile-reimbursements-scope-all")).toHaveAttribute("data-state", "active");
|
||||
await expect(page.getByText("其他员工")).toBeVisible();
|
||||
expect(requestedScopes.at(-1)).toBe("all");
|
||||
await expect(page.getByTestId("mobile-reimbursement-delete")).toHaveCount(0);
|
||||
|
||||
await page.getByText("其他员工").click();
|
||||
await expect(page).toHaveURL(new RegExp(`/mobile/reimbursements/${otherReimbursementId}$`));
|
||||
const routeState = await page.evaluate(() => window.history.state?.usr ?? null);
|
||||
expect(routeState?.from).toContain("status=reviewing");
|
||||
expect(routeState?.from).toContain("scope=all");
|
||||
|
||||
await page.goBack();
|
||||
await page.setViewportSize({ width: 667, height: 375 });
|
||||
await expect(page.getByTestId("mobile-reimbursements-scope-all")).toBeVisible();
|
||||
const horizontalOverflow = await page.evaluate(
|
||||
() => document.documentElement.scrollWidth - document.documentElement.clientWidth,
|
||||
);
|
||||
expect(horizontalOverflow).toBeLessThanOrEqual(2);
|
||||
});
|
||||
|
||||
test("报销明细记录切换时支持取消保持与确认放弃", async ({ page }) => {
|
||||
await page.setViewportSize({ width: 1280, height: 900 });
|
||||
await page.goto(`/reimbursements/${reimbursementId}`);
|
||||
|
||||
在新工单中引用
屏蔽一个用户