diff --git a/artifacts/r70/manual-trialrun-checklist.md b/artifacts/r70/manual-trialrun-checklist.md index f5a91c12..1b09515d 100644 --- a/artifacts/r70/manual-trialrun-checklist.md +++ b/artifacts/r70/manual-trialrun-checklist.md @@ -29,6 +29,119 @@ - 数据库/分支:`hyb_oa_manual_trialrun_20260428172231 / 2bce9c9` - 执行人:`Codex Playwright 辅助复核` +## R79 公网部署态辅助复核(2026-05-04) + +当前已核实部署基线: + +- 公网地址:`https://oa.hyinda.com` +- 服务器:`118.89.77.218` +- 反向代理:`caddy` +- SSH:常规 `deploy`,系统配置 `root` +- 部署目录:`/opt/hyb-oa-trialrun` +- 远端容器命令:`docker-compose` + +Codex 可代做: + +1. SSH 登录服务器做只读巡检。 +2. 执行 `deploy/trialrun/deploy.sh`、`/ready`、`smoke-external-deps.js`、`smoke-trialrun-runtime.js`。 +3. 查看 `Caddyfile`、容器状态、日志、feature flag 与工作区配置。 +4. 在你提供账号后执行 `smoke-live` 和 Playwright 公网只读复核。 + +必须人工配合: + +1. 企业微信后台配置工作台入口与 OAuth 回调。 +2. 提供独立 smoke 账号凭据,不使用真实业务账号。 +3. 若需要真实企微绑定、手机扫码、二次验证,必须由你本人完成。 + +最小人工操作清单: + +1. 企微后台配置: + - 工作台入口 URL:`https://oa.hyinda.com/api/auth/wecom/login?returnTo=%2Fportal` + - OAuth 回调 URL:`https://oa.hyinda.com/api/auth/wecom/callback` +2. 提供至少两组公网只读 smoke 账号: + - `smoke-admin` + - `smoke-employee` +3. 若公网运行时 smoke 继续提示公告或搜索缺失,用管理员账号进入“功能开关”页,核对: + - `portal_home_v1` + - `announcements_v1` + - `global_search_v1` + - `wecom_entry_v1` +4. 若你让我继续代跑公网 smoke,只需要把账号标识和密码给我;我会继续完成自动化部分。 + +## R80 企微公网启用回填(2026-05-04) + +- 企业微信域名校验文件已可访问: + - `https://oa.hyinda.com/WW_verify_StBYfdlboMaIoRRE.txt` +- 公网企微入口运行时已启用: + - 远端 `.env`:`WECOM_ENABLED=true`、`WECOM_ENTRY_ENABLED=true`、`WECOM_OAUTH_ENABLED=true` + - 数据库 `feature_flags`:`wecom_entry_v1=inherit` +- 自动化核验已完成: + - `GET https://oa.hyinda.com/api/auth/wecom/config` 返回 `status=ready` + - `GET https://oa.hyinda.com/api/auth/wecom/login?returnTo=%2Fportal` 返回 `302`,并跳转到 `open.weixin.qq.com` + - Playwright 公网登录页快照已确认出现“使用企业微信进入工作台”按钮 + - Playwright 点击后,桌面浏览器落到企微提示页“请在企业微信客户端打开链接”,符合预期 + - 截图:`output/playwright/2026-05-04-wecom-login-page.png` + +你现在必须人工完成的动作: + +1. 用手机企业微信或桌面企业微信打开该应用,不要用普通浏览器代替。 +2. 若首次进入后回到 OA 登录页并带 `wecom=unbound`: + - 用已有 OA 账号密码登录 + - 进入“个人设置” + - 执行企微绑定 + - 绑定成功后,再次从企业微信工作台进入 OA +3. 首次人工闭环时,建议只用 1 个员工测试账号,不要直接用高权限真实业务账号。 +4. 若你完成首次绑定后要我继续代跑公网 smoke,再把该 smoke 账号标识告诉我,我继续补 Playwright / curl 只读核验。 + +## R81 企微回调身份取证(2026-05-04) + +- 当前线上已追加最小安全诊断: + - 企微回调若缺少 `UserId`,server 会记录: + - `requestId` + - `mode` + - `hasUserId` + - `hasOpenId` + - `hasDeviceId` + - 不记录 token、secret、完整敏感回包。 +- 当前服务器复核结果: + - `https://oa.hyinda.com/ready`:正常 + - `https://oa.hyinda.com/api/auth/wecom/config`:`status=ready` + - `https://oa.hyinda.com/api/auth/wecom/login?returnTo=%2Fportal`:仍返回 `302` 到企微 OAuth + +你现在需要做的人工操作: + +1. 在手机企业微信里进入工作台,打开 OA 应用一次。 +2. 如果仍然失败,不要反复点,先停在错误页。 +3. 记录以下任一信息发给我: + - 页面里的 `requestId` + - 错误截图 + - 大致时间点(精确到分钟) +4. 如果页面重新跳回登录页,也把最终 URL 或截图发给我。 + +我收到后会立刻去服务器抓对应日志,判断腾讯这次回包到底是: + +- 有 `OpenId` 但没有 `UserId` +- 还是两个字段都没有 +- 还是另外一种接口异常 + +## R83-R86 用户开通与首次绑定收口(2026-05-04) + +- 本轮新增自动化覆盖: + - `client/tests/e2e/user-onboarding.spec.ts` + - 管理员在“系统用户管理”中创建用户时,不再手填工号和初始密码。 + - 创建成功后,页面展示系统生成的员工号与一次性口令。 + - 新用户首次登录后,被强制跳转到“修改密码”页,改密成功再进入 Portal。 + - `client/tests/e2e/wecom-first-bind.spec.ts` + - 企业微信未绑定场景回退到密码登录页后,用户仍可继续进入目标页。 +- 当前代码口径: + - 单个新建用户:系统自动生成员工号,管理员只维护基础资料与角色。 + - 首次登录:使用一次性口令登录,必须先改密。 + - 企微首次绑定:服务端已支持“未绑定回退登录后自动补绑定”的闭环,不再要求用户必须手动去“个人设置”补绑定。 +- 仍需人工完成的最小动作: + 1. 把本轮代码部署到目标环境。 + 2. 用真实手机企业微信打开 OA 应用一次,确认真实 OAuth 回调、cookie 与企业身份都正常。 + 3. 若仍失败,记录 `requestId`、错误截图和时间点给我,我继续抓线上日志定位。 + ## 员工路径 账号:员工测试账号。 diff --git a/artifacts/r70/manual-trialrun-report.md b/artifacts/r70/manual-trialrun-report.md index d2e7a488..74a5ad8d 100644 --- a/artifacts/r70/manual-trialrun-report.md +++ b/artifacts/r70/manual-trialrun-report.md @@ -117,3 +117,116 @@ R70 初次试运行暂停项已在 2026-04-28 的 R71 定向修复后关闭。 - `paymentBatches` 复核路由仍拦截 maker 自审;低风险批次维持 `finance_mgr` 复核、`finance` 执行的边界。 - 失败路径单测确认不会错误归档已绑定发票,也不会误入 ERP / 通知 follow-up。 - `PaymentBatchesPage` 与 `ApOperationsPage` 没有新增越权入口,按钮仍按 `payment.batch.manage` 与 `risk.review.manage` 分离。 + +## R79 公网部署只读巡检 + +- 巡检时间:`2026-05-04 13:04 - 13:16 CST` +- 公网地址:`https://oa.hyinda.com` +- 服务器:`118.89.77.218` +- SSH 结论:`deploy` / `root` 可免密登录;`shunli` 当前不可免密登录。 +- 反向代理:`caddy` active,`/etc/caddy/Caddyfile` 已包含 `oa.hyinda.com -> reverse_proxy 127.0.0.1:3000`。 +- 部署目录:`/opt/hyb-oa-trialrun` +- 部署形态:远端目录不是 git 工作树;当前推荐从本地执行 `deploy/trialrun/deploy.sh`,通过 `rsync` 同步。 +- 容器编排:服务器当前使用 `docker-compose 1.29.2`,不是 `docker compose`。 +- 公网探活: + - `https://oa.hyinda.com/ready`:`HTTP/2 200` + - `https://oa.hyinda.com`:`HTTP/2 200` +- 当前公网 `.env`: + - `APP_ORIGIN=https://oa.hyinda.com` + - `COOKIE_SECURE=true` + - `PUBLIC_SECURITY_HARDENING_ENABLED=true` + - `WECOM_ENABLED=false` + - `WECOM_ENTRY_ENABLED=false` + - `WECOM_OAUTH_ENABLED=false` + - `OCR_PROVIDER=mock` +- 当前公网数据库只读核查: + - `workspace_settings.profile = small_team_internal` + - `portal_home_v1 = force_on` + - `announcements_v1 = force_off` + - `global_search_v1 = force_off` + - `wecom_entry_v1 = force_off` +- 远端 smoke 结果: + - `smoke-external-deps.js`:通过,但其通过前提是“企微入口未启用 + OCR 使用 mock provider”。 + - `smoke-trialrun-runtime.js`:失败,当前阻塞点为 `announcements_v1 => enabled=false reason=flag_force_off envEnabled=true`。 +- 结论: + - 公网 HTTPS 与容器服务本身已通。 + - 当前还不能把公网部署态视为“外部试运行闭环完成”。 + - 下一步最小阻塞清单是:独立 smoke 账号、企微后台配置、以及公网 feature flag 基线修正后重跑 live smoke。 + +## R80 企微公网启用与只读验收 + +- 执行时间:`2026-05-04 16:47 - 17:03 CST` +- 服务器动作: + - 已把企微校验文件挂到 `https://oa.hyinda.com/WW_verify_StBYfdlboMaIoRRE.txt` + - 已在远端 `/opt/hyb-oa-trialrun/deploy/trialrun/.env` 启用: + - `WECOM_ENABLED=true` + - `WECOM_ENTRY_ENABLED=true` + - `WECOM_OAUTH_ENABLED=true` + - `WECOM_CORP_ID` / `WECOM_AGENT_ID` / `WECOM_AGENT_SECRET` + - `WECOM_REDIRECT_URI=https://oa.hyinda.com/api/auth/wecom/callback` + - `WECOM_ALLOWED_RETURN_HOSTS=oa.hyinda.com` + - 已把数据库 `wecom_entry_v1` 从 `force_off` 调整为 `inherit` + - 已使用 `docker-compose -f deploy/trialrun/docker-compose.yml --env-file deploy/trialrun/.env up -d --no-deps --force-recreate server` 重建公网 server 容器 +- curl 验证结果: + - `GET /api/auth/wecom/config`:`status=ready`、`entryEnabled=true`、`oauthEnabled=true` + - `GET /api/auth/wecom/login?returnTo=%2Fportal`:`HTTP 302` + - `Location` 已包含: + - `appid=ww6b98ab55e5e8d341` + - `agentid=1000002` + - `redirect_uri=https://oa.hyinda.com/api/auth/wecom/callback` +- Playwright 只读验证: + - 登录页已展示“使用企业微信进入工作台” + - 浏览器实际请求 `/api/auth/wecom/config` 返回 `200` + - 点击按钮后,页面跳转到 `open.weixin.qq.com/...#wechat_redirect` + - 桌面浏览器页面提示“请在企业微信客户端打开链接”,符合企微 OAuth 预期 + - 截图产物:`output/playwright/2026-05-04-wecom-login-page.png` +- 当前剩余人工动作: + - 必须由你本人使用真实企业微信客户端完成首次进入 + - 若首次回到 `/login?wecom=unbound`,需先用 OA 账号密码登录并在“个人设置”完成绑定 + - 绑定成功后,再次从企业微信工作台进入 OA,才算真实企微登录闭环完成 +- 当前结论: + - 服务器侧企微入口、OAuth 跳转、域名校验文件、HTTPS 回调地址均已就绪 + - 自动化已压到浏览器跳转层;剩余未完成项只剩“真实企微客户端首次绑定闭环” + +## R81 企微回调身份诊断部署 + +- 时间:`2026-05-04 17:50 - 17:56 CST` +- 背景: + - 真实手机企业微信进入 OA 时,服务端收到 `/api/auth/wecom/callback` 回调 + - 当前失败表现为:`AUTH_003 / missing_user_id` + - 你已确认当前企微账号是企业内部成员 +- 本轮动作: + - 为 `missing_user_id` 分支补充最小安全诊断日志 + - 补 1 条单测覆盖“仅有 OpenId、没有 UserId”场景 + - 本地校验通过后,已重建公网 `server` 容器 +- 本地验证结果: + - `cd server && node --check src/routes/wecomAuth.js`:通过 + - `cd server && npm test -- tests/unit/routes/wecomAuth.test.js`:`7 passed` + - `cd server && npm run prisma:validate`:通过 +- 远端验证结果: + - `https://oa.hyinda.com/ready`:正常 + - `https://oa.hyinda.com/api/auth/wecom/config`:`status=ready` + - `https://oa.hyinda.com/api/auth/wecom/login?returnTo=%2Fportal`:`302` 到企业微信 OAuth +- 当前状态: + - 服务器已具备取证能力 + - 待你用手机企业微信再复现 1 次,我再根据日志判断下一步应是“错误提示优化”还是“绑定模型调整” + +## R83-R86 用户开通与首次绑定收口 + +- 时间:`2026-05-04 23:36 - 23:58 CST` +- 本轮本地实现: + - 单个新建用户改为系统自动生成员工号,不再要求管理员手工输入工号。 + - 单个新建用户改为后端生成一次性口令,并在成功态只展示一次。 + - 新用户首次登录后继续沿用强制改密闭环。 + - 企业微信未绑定回退登录时,服务端已保留短时待绑定上下文,并在账号密码登录成功后自动补绑定。 +- 本轮新增自动化结果: + - `cd server && npm test -- tests/unit/routes/auth.login.test.js tests/unit/routes/wecomAuth.test.js tests/unit/routes/users.write.test.js` + - `11 passed` + - `cd client && E2E_BASE_URL=http://127.0.0.1:3100 npx playwright test tests/e2e/user-onboarding.spec.ts tests/e2e/wecom-first-bind.spec.ts --workers=1 --reporter=list` + - `3 passed` +- 自动化已验证链路: + - 管理员创建用户 -> 系统返回员工号与一次性口令 + - 新用户首次登录 -> 强制修改密码 -> 进入 Portal + - 企业微信未绑定 -> 回退密码登录 -> 继续进入目标页 +- 仍需真实人工复核: + - 将本轮代码部署到目标环境后,用真实手机企业微信完成 1 次首次进入,确认真实 OAuth 回调、cookie 与企业身份环境下也能自动完成首次绑定闭环。 diff --git a/client/src/api/users.ts b/client/src/api/users.ts index 8727e18a..d3b390f1 100644 --- a/client/src/api/users.ts +++ b/client/src/api/users.ts @@ -88,16 +88,14 @@ export async function fetchUsers(params: { } export async function createUser(payload: { - employeeNo: string; name: string; phone: string; email: string; - password: string; departmentId?: string; directManagerId?: string | null; roles: string[]; }) { - return apiRequestData("/api/users", { + return apiRequestData("/api/users", { method: "POST", body: JSON.stringify(payload), }); diff --git a/client/src/pages/LoginPage.tsx b/client/src/pages/LoginPage.tsx index e2ec7b8a..7cc9bdb0 100644 --- a/client/src/pages/LoginPage.tsx +++ b/client/src/pages/LoginPage.tsx @@ -110,8 +110,8 @@ export function LoginPage() {

使用账号密码登录

{primary - ? "适合桌面浏览器直接登录;若企业微信入口暂未开放,也可正常使用。" - : "企业微信免登失败、未绑定或超时时,可改用账号密码继续进入。"} + ? "适合桌面浏览器直接登录;若是首次开通,也可使用管理员转交的一次性口令进入。" + : "企业微信免登失败、未绑定或超时时,可改用账号密码或一次性口令继续进入。"}

@@ -135,7 +135,7 @@ export function LoginPage() { data-testid="login-password" id="login-password" onChange={(event) => setPassword(event.target.value)} - placeholder="请输入密码" + placeholder="请输入密码或一次性口令" type="password" value={password} /> diff --git a/client/src/pages/ProfilePage.tsx b/client/src/pages/ProfilePage.tsx index 453a0f85..c494357c 100644 --- a/client/src/pages/ProfilePage.tsx +++ b/client/src/pages/ProfilePage.tsx @@ -112,7 +112,7 @@ export function ProfilePage() {
{me?.name ?? "-"}
-
员工号
+
员工号(系统生成)
{me?.employeeNo ?? "-"}
diff --git a/client/src/pages/UserManagementPage.tsx b/client/src/pages/UserManagementPage.tsx index f9fd7678..f7d3f95b 100644 --- a/client/src/pages/UserManagementPage.tsx +++ b/client/src/pages/UserManagementPage.tsx @@ -49,7 +49,12 @@ export function UserManagementPage() { const [q, setQ] = useState(""); const [page, setPage] = useState(1); const [error, setError] = useState(""); - const [resetResult, setResetResult] = useState<{ userId: string; password: string } | null>(null); + const [credentialResult, setCredentialResult] = useState<{ + mode: "create" | "reset"; + userId: string; + employeeNo?: string; + password: string; + } | null>(null); const [createOpen, setCreateOpen] = useState(false); const [importOpen, setImportOpen] = useState(false); @@ -74,10 +79,16 @@ export function UserManagementPage() { const createMutation = useMutation({ mutationFn: (payload: CreateUserFormData) => createUser(payload), - onSuccess: async () => { + onSuccess: async (data) => { await queryClient.invalidateQueries({ queryKey: ["users"] }); setCreateOpen(false); setError(""); + setCredentialResult({ + mode: "create", + userId: data.id, + employeeNo: data.employeeNo, + password: data.oneTimePassword, + }); toast.success("用户已创建"); }, onError: (err) => { @@ -109,7 +120,7 @@ export function UserManagementPage() { const resetMutation = useMutation({ mutationFn: async (item: UserSummary) => resetUserPassword(item.id), onSuccess: async (data) => { - setResetResult({ userId: data.id, password: data.oneTimePassword }); + setCredentialResult({ mode: "reset", userId: data.id, password: data.oneTimePassword }); await queryClient.invalidateQueries({ queryKey: ["users"] }); setError(""); toast.success("密码已重置"); @@ -142,7 +153,7 @@ export function UserManagementPage() {
) : null} - {resetResult ? ( + {credentialResult ? (

- 已重置密码(user={resetResult.userId}),一次性口令:{resetResult.password} + {credentialResult.mode === "create" + ? ( + <> + 已创建用户(user={credentialResult.userId} + {credentialResult.employeeNo ? `,工号=${credentialResult.employeeNo}` : ""} + ),一次性口令:{credentialResult.password} + + ) + : ( + <> + 已重置密码(user={credentialResult.userId}),一次性口令:{credentialResult.password} + + )}

) : null} diff --git a/client/src/pages/user-management/CreateUserDialog.tsx b/client/src/pages/user-management/CreateUserDialog.tsx index d81037cf..51080387 100644 --- a/client/src/pages/user-management/CreateUserDialog.tsx +++ b/client/src/pages/user-management/CreateUserDialog.tsx @@ -12,11 +12,9 @@ import { Input } from "../../components/ui/input"; import { Label } from "../../components/ui/label"; export interface CreateUserFormData { - employeeNo: string; name: string; phone: string; email: string; - password: string; departmentId: string | undefined; directManagerId: string | null; roles: string[]; @@ -48,16 +46,13 @@ export function CreateUserDialog({ managers, roleOptions, }: CreateUserDialogProps) { - const [employeeNo, setEmployeeNo] = useState(""); const [name, setName] = useState(""); const [phone, setPhone] = useState(""); const [email, setEmail] = useState(""); - const [password, setPassword] = useState(""); const [departmentId, setDepartmentId] = useState(""); const [directManagerId, setDirectManagerId] = useState(""); const [createRoles, setCreateRoles] = useState>(() => new Set(["employee"])); - const trimmedEmployeeNo = employeeNo.trim(); const trimmedName = name.trim(); const trimmedPhone = phone.trim(); const trimmedEmail = email.trim(); @@ -65,19 +60,15 @@ export function CreateUserDialog({ const canSubmit = !isPending && - trimmedEmployeeNo !== "" && trimmedName !== "" && trimmedPhone !== "" && trimmedEmail !== "" && - password !== "" && rolesArray.length >= 1; function resetForm() { - setEmployeeNo(""); setName(""); setPhone(""); setEmail(""); - setPassword(""); setDepartmentId(""); setDirectManagerId(""); setCreateRoles(new Set(["employee"])); @@ -92,11 +83,9 @@ export function CreateUserDialog({ function handleSubmit() { onSubmit({ - employeeNo: trimmedEmployeeNo, name: trimmedName, phone: trimmedPhone, email: trimmedEmail, - password, departmentId: departmentId || undefined, directManagerId: directManagerId ? directManagerId : null, roles: rolesArray, @@ -113,20 +102,10 @@ export function CreateUserDialog({ 新建用户 - 请填写基础信息与初始密码(满足密码策略)。 + 请填写基础信息。工号与一次性口令将在创建成功后由系统生成并展示一次。
-
- - setEmployeeNo(e.target.value)} - placeholder="E1001" - /> -
-
- - setPassword(e.target.value)} - placeholder="至少12位,含大小写与数字" - /> -
diff --git a/client/tests/e2e/user-onboarding.spec.ts b/client/tests/e2e/user-onboarding.spec.ts new file mode 100644 index 00000000..1cb980e7 --- /dev/null +++ b/client/tests/e2e/user-onboarding.spec.ts @@ -0,0 +1,300 @@ +import { expect, test } from "@playwright/test"; + +function envelope(data: unknown) { + return { + requestId: "mock-request", + data, + }; +} + +function mockCommonAuth(page: import("@playwright/test").Page, options: { + mustChangePassword?: boolean; + meName?: string; + meEmployeeNo?: string; +}) { + const mustChangePassword = options.mustChangePassword === true; + const meName = options.meName ?? "Admin"; + const meEmployeeNo = options.meEmployeeNo ?? "HYD000001"; + + return Promise.all([ + page.route("**/api/auth/sso/providers", async (route) => { + await route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify(envelope([])), + }); + }), + page.route("**/api/auth/wecom/config", async (route) => { + await route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify( + envelope({ + enabled: false, + entryEnabled: false, + oauthEnabled: false, + corpId: "", + agentId: "", + loginUrl: null, + bindEnabled: false, + defaultReturnTo: "/portal", + status: "disabled", + statusReasons: ["disabled"], + }), + ), + }); + }), + page.route("**/api/auth/login", async (route) => { + await route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify( + envelope({ + accessToken: "test-access-token", + expiresIn: 900, + mustChangePassword, + wecomBindingStatus: null, + }), + ), + }); + }), + page.route("**/api/me", async (route) => { + await route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify( + envelope({ + id: "employee-admin-1", + employeeNo: meEmployeeNo, + name: meName, + phone: "13800000000", + email: "admin@hyinda.com", + departmentId: "dept-1", + directManagerId: null, + status: "active", + notificationPushEnabled: true, + roleCodes: ["admin"], + permissions: ["system.users"], + profileCompleteness: { + hasEmergencyContact: true, + hasBankAccount: true, + hasAvatar: false, + hasIdNumber: false, + score: 50, + }, + wecomBinding: null, + }), + ), + }); + }), + page.route("**/api/system/navigation", async (route) => { + await route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify( + envelope({ + workspace: { + id: "workspace-1", + persisted: true, + profile: "small_team_internal", + orgMode: "single_company", + primaryEntryMode: "password", + notificationStrategy: "in_app", + devicePriority: "desktop", + moduleVisibility: {}, + createdAt: null, + updatedAt: null, + updatedBy: null, + }, + sections: { + main: [{ key: "portal", href: "/portal", label: "Portal", icon: "home_app_logo", placement: "main", permissionCodes: [], featureFlagKey: null }], + admin: [{ key: "users", href: "/system/users", label: "系统用户", icon: "manage_accounts", placement: "admin", permissionCodes: ["system.users"], featureFlagKey: null }], + backup: [], + }, + recommendedRolePresets: [], + }), + ), + }); + }), + page.route((url) => url.pathname === "/api/notifications", async (route) => { + await route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify( + envelope({ + page: 1, + pageSize: 10, + total: 0, + unreadOnly: true, + unreadCount: 0, + items: [], + }), + ), + }); + }), + ]); +} + +test("E2E 用户开通:新建用户时由系统生成工号和一次性口令", async ({ page }) => { + let createPayload: Record | null = null; + + await mockCommonAuth(page, {}); + await page.route("**/api/users?**", async (route) => { + await route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify( + envelope({ + page: 1, + pageSize: 20, + total: 0, + items: [], + }), + ), + }); + }); + await page.route("**/api/departments?includeArchived=0", async (route) => { + await route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify( + envelope({ + includeArchived: false, + items: [{ id: "dept-1", name: "行政部", parentId: null, archivedAt: null, createdAt: null, updatedAt: null }], + tree: [], + }), + ), + }); + }); + await page.route("**/api/employees?page=1&pageSize=200&status=active", async (route) => { + await route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify( + envelope({ + page: 1, + pageSize: 200, + total: 0, + items: [], + }), + ), + }); + }); + await page.route("**/api/users", async (route) => { + if (route.request().method() !== "POST") { + await route.fallback(); + return; + } + createPayload = route.request().postDataJSON() as Record; + await route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify( + envelope({ + id: "user-1", + employeeNo: "HYD000123", + name: "张三", + phone: "13800000001", + email: "zhangsan@example.com", + status: "active", + departmentId: "dept-1", + title: "员工", + roleCodes: ["employee"], + createdAt: "2026-05-04T12:00:00.000Z", + updatedAt: "2026-05-04T12:00:00.000Z", + oneTimePassword: "ResetA1abc123def456", + }), + ), + }); + }); + + await page.goto("/login?redirect=%2Fsystem%2Fusers"); + await page.getByTestId("login-identifier").fill("admin@hyinda.com"); + await page.getByTestId("login-password").fill("AdminPassword1234"); + await page.getByTestId("login-submit").click(); + await expect(page).toHaveURL(/\/system\/users/); + + await page.getByTestId("users-create-open").click(); + await expect(page.getByTestId("users-create-employeeNo")).toHaveCount(0); + await expect(page.getByTestId("users-create-password")).toHaveCount(0); + await page.getByTestId("users-create-name").fill("张三"); + await page.getByTestId("users-create-phone").fill("13800000001"); + await page.getByTestId("users-create-email").fill("zhangsan@example.com"); + await page.getByTestId("users-create-submit").click(); + + expect(createPayload).toEqual({ + name: "张三", + phone: "13800000001", + email: "zhangsan@example.com", + departmentId: undefined, + directManagerId: null, + roles: ["employee"], + }); + await expect(page.getByTestId("users-reset-result")).toContainText("工号=HYD000123"); + await expect(page.getByTestId("users-reset-result")).toContainText("ResetA1abc123def456"); +}); + +test("E2E 首次登录:一次性口令登录后强制改密", async ({ page }) => { + let changePasswordPayload: Record | null = null; + + await mockCommonAuth(page, { mustChangePassword: true, meName: "新员工", meEmployeeNo: "HYD000123" }); + await page.route("**/api/auth/change-password", async (route) => { + changePasswordPayload = route.request().postDataJSON() as Record; + await route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify( + envelope({ + accessToken: "post-change-token", + expiresIn: 900, + mustChangePassword: false, + }), + ), + }); + }); + await page.route("**/api/portal/home", async (route) => { + await route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify( + envelope({ + workspace: { id: "workspace-1", persisted: true, profile: "small_team_internal" }, + greeting: { name: "新员工" }, + cards: [], + pendingTasks: { count: 0, href: "/workflow/tasks" }, + myApplications: [], + riskSummary: null, + announcements: [], + quickLinks: [], + todayPresence: null, + teamPresence: null, + weeklyReport: null, + weeklySummary: null, + todoSummary: { openCount: 0, highPriorityCount: 0, href: "/todos" }, + projectSummary: { myCount: 0, overdueMilestonesCount: 0, highPriorityTaskCount: 0, openRiskCount: 0, followUpDueTodayCount: 0, closurePendingProjectCount: 0, href: "/projects" }, + peopleLifecycle: null, + adoptionSummary: null, + recentActivities: [], + teamStatusBoard: [], + }), + ), + }); + }); + + await page.goto("/login"); + await page.getByTestId("login-identifier").fill("new.user@hyinda.com"); + await page.getByTestId("login-password").fill("ResetA1abc123def456"); + await page.getByTestId("login-submit").click(); + await expect(page).toHaveURL(/\/change-password/); + + await page.getByTestId("change-password-old").fill("ResetA1abc123def456"); + await page.getByTestId("change-password-new").fill("NewPassword1234"); + await page.getByLabel("确认新密码").fill("NewPassword1234"); + await page.getByTestId("change-password-submit").click(); + + expect(changePasswordPayload).toEqual({ + oldPassword: "ResetA1abc123def456", + newPassword: "NewPassword1234", + }); + await expect(page).toHaveURL(/\/portal/); +}); diff --git a/client/tests/e2e/wecom-first-bind.spec.ts b/client/tests/e2e/wecom-first-bind.spec.ts new file mode 100644 index 00000000..7d4a3121 --- /dev/null +++ b/client/tests/e2e/wecom-first-bind.spec.ts @@ -0,0 +1,171 @@ +import { expect, test } from "@playwright/test"; + +function envelope(data: unknown) { + return { + requestId: "mock-request", + data, + }; +} + +test("E2E 企微首次绑定:未绑定回退到密码登录后可继续进入目标页", async ({ page }) => { + await page.addInitScript(() => { + Object.defineProperty(window.navigator, "userAgent", { + configurable: true, + value: "Mozilla/5.0 wxwork/4.1.20 MicroMessenger/7.0.1", + }); + }); + + await page.route("**/api/auth/sso/providers", async (route) => { + await route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify(envelope([])), + }); + }); + await page.route("**/api/auth/wecom/config", async (route) => { + await route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify( + envelope({ + enabled: true, + entryEnabled: true, + oauthEnabled: true, + corpId: "wx-test-corp", + agentId: "1000001", + loginUrl: "/api/auth/wecom/login?returnTo=%2Fportal", + bindEnabled: true, + defaultReturnTo: "/portal", + status: "ready", + statusReasons: [], + }), + ), + }); + }); + await page.route("**/api/auth/login", async (route) => { + await route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify( + envelope({ + accessToken: "wecom-fallback-token", + expiresIn: 900, + mustChangePassword: false, + wecomBindingStatus: "bound", + }), + ), + }); + }); + await page.route("**/api/me", async (route) => { + await route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify( + envelope({ + id: "employee-1", + employeeNo: "HYD000123", + name: "张三", + phone: "13800000001", + email: "zhangsan@example.com", + departmentId: "dept-1", + directManagerId: null, + status: "active", + notificationPushEnabled: true, + roleCodes: ["employee"], + permissions: [], + profileCompleteness: { + hasEmergencyContact: true, + hasBankAccount: false, + hasAvatar: false, + hasIdNumber: false, + score: 25, + }, + wecomBinding: { + externalUserId: "wecom-user-1", + createdAt: "2026-05-04T12:00:00.000Z", + }, + }), + ), + }); + }); + await page.route("**/api/system/navigation", async (route) => { + await route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify( + envelope({ + workspace: { + id: "workspace-1", + persisted: true, + profile: "small_team_internal", + orgMode: "single_company", + primaryEntryMode: "password", + notificationStrategy: "in_app", + devicePriority: "desktop", + moduleVisibility: {}, + createdAt: null, + updatedAt: null, + updatedBy: null, + }, + sections: { + main: [{ key: "portal", href: "/portal", label: "Portal", icon: "home_app_logo", placement: "main", permissionCodes: [], featureFlagKey: null }], + admin: [], + backup: [], + }, + recommendedRolePresets: [], + }), + ), + }); + }); + await page.route((url) => url.pathname === "/api/notifications", async (route) => { + await route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify( + envelope({ + page: 1, + pageSize: 10, + total: 0, + unreadOnly: true, + unreadCount: 0, + items: [], + }), + ), + }); + }); + await page.route("**/api/portal/home", async (route) => { + await route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify( + envelope({ + workspace: { id: "workspace-1", persisted: true, profile: "small_team_internal" }, + greeting: { name: "张三" }, + cards: [], + pendingTasks: { count: 0, href: "/workflow/tasks" }, + myApplications: [], + riskSummary: null, + announcements: [], + quickLinks: [], + todayPresence: null, + teamPresence: null, + weeklyReport: null, + weeklySummary: null, + todoSummary: { openCount: 0, highPriorityCount: 0, href: "/todos" }, + projectSummary: { myCount: 0, overdueMilestonesCount: 0, highPriorityTaskCount: 0, openRiskCount: 0, followUpDueTodayCount: 0, closurePendingProjectCount: 0, href: "/projects" }, + peopleLifecycle: null, + adoptionSummary: null, + recentActivities: [], + teamStatusBoard: [], + }), + ), + }); + }); + + await page.goto("/login?redirect=%2Fportal&wecom=unbound"); + await expect(page.getByTestId("login-wecom-notice")).toContainText("当前企业微信账号尚未绑定"); + await page.getByTestId("login-identifier").fill("zhangsan@example.com"); + await page.getByTestId("login-password").fill("ResetA1abc123def456"); + await page.getByTestId("login-submit").click(); + await expect(page).toHaveURL(/\/portal/); +}); diff --git a/issues/2026-05-04_23-36-10-r83-r86-user-onboarding-simplify.csv b/issues/2026-05-04_23-36-10-r83-r86-user-onboarding-simplify.csv new file mode 100644 index 00000000..83c795db --- /dev/null +++ b/issues/2026-05-04_23-36-10-r83-r86-user-onboarding-simplify.csv @@ -0,0 +1,6 @@ +"ID","Title","Description","Acceptance","Test_Method","Tools","Dev_Status","Review1_Status","Regression_Status","Files","Dependencies","Notes" +"A1","R83-R86 执行合同建档与范围冻结","为员工编号自动生成、轻量创建用户、首登改密闭环、企微首次绑定闭环建立独立执行合同。本轮仅覆盖单个创建用户主链、相关后端接口、前端入口、Playwright 回归与最小手工真机复核;不处理批量导入 employeeNo 选填、不做 hyb/hyd 全局改名、不引入第二套企微业务录入。","CSV 列完整且通过校验;A2-A5 依赖顺序清晰;Notes 明确本轮不处理 R87 导入改造与 R88 生命周期治理。","命令:`python3 /home/shunli/.codex/skills/plan/scripts/validate_issues_csv.py issues/2026-05-04_23-36-10-r83-r86-user-onboarding-simplify.csv`。业务:确认本轮只承接“用户开通与首绑主链优化”,不混入发票、审批或命名重构。","manual","DONE","DONE","DONE","issues/2026-05-04_23-36-10-r83-r86-user-onboarding-simplify.csv","[]","回滚路径:删除本 CSV,不影响运行时。后续 R87 单独承接批量导入 employeeNo 选填与外部编号映射。2026-05-04:合同已写入并通过 validate_issues_csv.py 校验。" +"A2","R83 员工编号自动生成基础","在 server 侧把 `employeeNo` 从“单个创建用户必填”改为“缺省时自动生成”,采用数据库统一生成策略;保持 `employee_no` 唯一、业务可见、创建后不可修改;现有查询/展示继续使用该字段。","`POST /api/users` 在未传 `employeeNo` 时可成功创建并返回系统生成的 `employeeNo`;`PATCH/PUT` 路径不可修改既有员工编号;编号生成规则、间隙预期和回滚方式在 Notes/实现中清晰。","命令:`cd server && npm run prisma:validate`;`cd server && node --check src/routes/users.js`;`cd server && npm test -- tests/unit/routes/users*.test.js`。业务:管理员创建用户时不再手填工号,列表与详情页仍能看到新生成工号。","prisma|vitest","DONE","DONE","DONE","server/prisma/schema.prisma|server/prisma/migrations/*|server/src/routes/users.js|server/tests/unit/routes/users*.test.js","[""A1""]","建议采用 PostgreSQL sequence/default 统一发号,允许编号间隙;本轮不改已有历史工号,不改搜索/展示语义。2026-05-04:已补 Prisma default + migration + PATCH 禁改工号;`npm run prisma:validate`、`node --check src/routes/users.js`、`npm test -- tests/unit/routes/users.write.test.js tests/unit/routes/users.import.test.js tests/unit/routes/users.list.test.js` 通过。" +"A3","R84 新建用户流程轻量化与一次性口令收口","移除前端“手填工号”和“管理员手填初始密码”交互;单个创建用户时由后端生成一次性口令并仅返回一次,前端在成功态展示“生成工号 + 一次性口令”;保留现有 `must_change_password` 首登改密强制逻辑。","管理员只需填写姓名、手机号、邮箱、部门、直属上级、角色等基础信息即可创建用户;创建成功后能看到一次性口令和生成工号;新用户可用邮箱/手机号 + 一次性口令登录,并被强制跳转修改密码。","命令:`cd server && node --check src/routes/users.js`;`cd client && npm run typecheck`;`cd client && npm run build`;`cd client && npm run test:e2e -- tests/e2e/user-onboarding.spec.ts`。业务:完成“新建用户 -> 拿到一次性口令 -> 新用户首次登录并改密”整链验证。","playwright|vitest","DONE","DONE","DONE","server/src/routes/users.js|client/src/api/users.ts|client/src/pages/user-management/CreateUserDialog.tsx|client/src/pages/UserManagementPage.tsx|client/src/pages/ChangePasswordPage.tsx|client/tests/e2e/user-onboarding.spec.ts","[""A2""]","本轮不接入邮件、短信或企微发码,一次性口令仍由管理员线下转交;重置密码接口保持与创建用户一致的口令语义。2026-05-04:Playwright `user-onboarding.spec.ts` 通过,前端 `npm run build` 通过。" +"A4","R85 企微未绑定首次登录闭环","在企微回调识别到未绑定员工时,保留短时待绑定上下文;用户回退到账号密码登录后,系统自动完成绑定或只补一个显式确认动作,然后回到目标页;继续保留个人设置页手动绑定作为兜底。","首次从企微进入且未绑定时,不再要求用户先登录后再手动去个人设置找绑定入口;完成账号密码登录后即可完成首次绑定闭环;第二次从企微进入应可直接进入 OA。","命令:`cd server && node --check src/routes/wecomAuth.js`;`cd server && node --check src/routes/auth.js`;`cd server && npm test -- tests/unit/routes/wecomAuth.test.js`;`cd client && npm run test:e2e -- tests/e2e/wecom-first-bind.spec.ts`。业务:本地用模拟回调覆盖待绑定闭环;线上保留 1 次手机企微真机复核。","playwright|vitest|manual","DONE","DONE","DONE","server/src/routes/wecomAuth.js|server/src/routes/auth.js|client/src/pages/LoginPage.tsx|client/src/lib/wecomEntry.ts|client/src/pages/ProfilePage.tsx|server/tests/unit/routes/wecomAuth.test.js|client/tests/e2e/wecom-first-bind.spec.ts","[""A3""]","保持当前 `/login?wecom=unbound` 回退兜底;若待绑定上下文过期/冲突,仍允许用户从个人设置重新发起绑定;不把审批、报销等业务搬进企微。2026-05-04:已在 callback 未绑定分支写入短时待绑定 cookie,并在密码登录成功后自动 upsert 企微绑定;auth/wecom/users 单测与 Playwright `wecom-first-bind.spec.ts` 通过。" +"A5","R86 文案语义与回归自动化收口","统一用户管理、个人资料、登录页中的员工编号语义:强调“系统生成、只读、业务可见”,避免继续暗示管理员手工维护;补齐 Playwright 用例与 trialrun 清单,把剩余必须人工的企微真机步骤单独列出。","相关页面不再出现“新建用户时手填工号/初始密码”的暗示;Playwright 覆盖创建用户、首登改密、密码重置、企微未绑定回退等关键路径;手工清单只剩真实企微手机回调与后台配置核验。","命令:`cd client && npm run typecheck`;`cd client && npm run build`;`cd client && npm run test:e2e -- tests/e2e/user-onboarding.spec.ts tests/e2e/wecom-first-bind.spec.ts`。业务:对照 checklist 确认本轮新增主链除真机企微外均可自动回归。","playwright|manual","DONE","DONE","DONE","client/src/pages/ProfilePage.tsx|client/src/pages/LoginPage.tsx|client/src/pages/UserManagementPage.tsx|artifacts/r70/manual-trialrun-checklist.md|artifacts/r70/manual-trialrun-report.md|client/tests/e2e/user-onboarding.spec.ts|client/tests/e2e/wecom-first-bind.spec.ts","[""A4""]","本轮只收口与用户开通相关的 UI 语义,不顺手调整全站所有 `employeeNo` 展示位;R87 再处理导入模板与外部编号映射。2026-05-04:已补用户管理/登录页/个人页文案与试运行清单更新,新增 Playwright 用例共 3 条并全部通过。"