R84 R86 收口用户开通界面与自动化回归

这个提交包含在:
2026-05-05 00:06:12 +08:00
父节点 d791357ca2
当前提交 6f22dbb1c4
共修改 10 个文件,包含 738 行新增和 46 行删除
+113
查看文件
@@ -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`、错误截图和时间点给我,我继续抓线上日志定位。
## 员工路径
账号:员工测试账号。
+113
查看文件
@@ -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 与企业身份环境下也能自动完成首次绑定闭环。
+1 -3
查看文件
@@ -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<UserSummary>("/api/users", {
return apiRequestData<UserSummary & { oneTimePassword: string }>("/api/users", {
method: "POST",
body: JSON.stringify(payload),
});
+3 -3
查看文件
@@ -110,8 +110,8 @@ export function LoginPage() {
<h2 className="mt-2 text-xl font-semibold tracking-tight text-slate-900">使用账号密码登录</h2>
<p className="mt-2 text-sm leading-6 text-slate-600">
{primary
? "适合桌面浏览器直接登录;若企业微信入口暂未开放,也可正常使用。"
: "企业微信免登失败、未绑定或超时时,可改用账号密码继续进入。"}
? "适合桌面浏览器直接登录;若是首次开通,也可使用管理员转交的一次性口令进入。"
: "企业微信免登失败、未绑定或超时时,可改用账号密码或一次性口令继续进入。"}
</p>
</div>
@@ -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}
/>
+1 -1
查看文件
@@ -112,7 +112,7 @@ export function ProfilePage() {
<dd className="mt-1 font-medium text-zinc-900">{me?.name ?? "-"}</dd>
</div>
<div>
<dt className="text-zinc-500">员工号</dt>
<dt className="text-zinc-500">员工号(系统生成)</dt>
<dd className="mt-1 font-medium text-zinc-900">{me?.employeeNo ?? "-"}</dd>
</div>
<div>
+29 -6
查看文件
@@ -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() {
<div className="space-y-6">
<PageHeader
title="系统用户管理"
subtitle="创建/启用/禁用账号,重置密码并分配角色(仅试运行最小运营)。"
subtitle="创建/启用/禁用账号,系统自动生成工号并展示一次性口令,管理员只负责最小开通与角色分配。"
actions={
<div className="flex gap-2">
<UserImportDialog
@@ -184,9 +195,21 @@ export function UserManagementPage() {
</p>
) : null}
{resetResult ? (
{credentialResult ? (
<p className="rounded-lg border border-amber-200 bg-amber-50 px-3 py-2 text-sm text-amber-800" data-testid="users-reset-result">
已重置密码(user={resetResult.userId}),一次性口令:<span className="font-mono">{resetResult.password}</span>
{credentialResult.mode === "create"
? (
<>
已创建用户(user={credentialResult.userId}
{credentialResult.employeeNo ? `,工号=${credentialResult.employeeNo}` : ""}
),一次性口令:<span className="font-mono">{credentialResult.password}</span>
</>
)
: (
<>
已重置密码(user={credentialResult.userId}),一次性口令:<span className="font-mono">{credentialResult.password}</span>
</>
)}
</p>
) : null}
@@ -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<Set<string>>(() => 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({
<DialogContent className="max-w-lg">
<DialogHeader>
<DialogTitle>新建用户</DialogTitle>
<DialogDescription>请填写基础信息与初始密码(满足密码策略)。</DialogDescription>
<DialogDescription>请填写基础信息。工号与一次性口令将在创建成功后由系统生成并展示一次。</DialogDescription>
</DialogHeader>
<div className="grid gap-4">
<div className="grid gap-2">
<Label htmlFor="employeeNo">工号</Label>
<Input
id="employeeNo"
data-testid="users-create-employeeNo"
value={employeeNo}
onChange={(e) => setEmployeeNo(e.target.value)}
placeholder="E1001"
/>
</div>
<div className="grid gap-2">
<Label htmlFor="name">姓名</Label>
<Input
@@ -157,17 +136,6 @@ export function CreateUserDialog({
placeholder="[email protected]"
/>
</div>
<div className="grid gap-2">
<Label htmlFor="password">初始密码</Label>
<Input
id="password"
data-testid="users-create-password"
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="至少12位,含大小写与数字"
/>
</div>
<div className="grid gap-2">
<Label htmlFor="departmentId">部门(可选)</Label>
+300
查看文件
@@ -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: "[email protected]",
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<string, unknown> | 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<string, unknown>;
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify(
envelope({
id: "user-1",
employeeNo: "HYD000123",
name: "张三",
phone: "13800000001",
email: "[email protected]",
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("[email protected]");
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("[email protected]");
await page.getByTestId("users-create-submit").click();
expect(createPayload).toEqual({
name: "张三",
phone: "13800000001",
email: "[email protected]",
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<string, unknown> | 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<string, unknown>;
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("[email protected]");
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/);
});
+171
查看文件
@@ -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: "[email protected]",
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("[email protected]");
await page.getByTestId("login-password").fill("ResetA1abc123def456");
await page.getByTestId("login-submit").click();
await expect(page).toHaveURL(/\/portal/);
});
@@ -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 条并全部通过。"
1 ID Title Description Acceptance Test_Method Tools Dev_Status Review1_Status Regression_Status Files Dependencies Notes
2 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 校验。
3 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` 通过。
4 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` 通过。
5 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` 通过。
6 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 条并全部通过。