R82 修复企微 userid 兼容登录
这个提交包含在:
@@ -0,0 +1,4 @@
|
|||||||
|
"ID","Title","Description","Acceptance","Test_Method","Tools","Dev_Status","Review1_Status","Regression_Status","Files","Dependencies","Notes"
|
||||||
|
"A1","R82 执行合同建档与兼容修复范围冻结","基于 R81 线上取证结果,为企业微信 `auth/getuserinfo` 回包字段名兼容缺失建立独立执行合同。本轮范围仅覆盖:后端字段归一化兼容(`UserId/userid`、同类大小写候选字段)、单测、远端部署与真机复核;不改数据库 schema,不改绑定模型,不改前端。","CSV 列完整且通过校验;A2-A3 依赖顺序清晰;Notes 明确本轮不引入迁移、不改登录状态机,只修复字段兼容。","命令:`python3 /home/shunli/.codex/skills/plan/scripts/validate_issues_csv.py issues/2026-05-04_20-53-39-r82-wecom-userid-compatibility.csv`。业务:确认本轮只承接“企微回包字段兼容修复”,不混入 UI、权限或绑定模型改造。","manual","DONE","DONE","DONE","issues/2026-05-04_20-53-39-r82-wecom-userid-compatibility.csv","[]","回滚路径:删除本 CSV,不影响运行时。R81 保持为诊断快照,R82 独立承接兼容修复。2026-05-04:R82 合同已写入并通过 validate_issues_csv.py 校验。"
|
||||||
|
"A2","企业微信回包 `userid` 兼容归一化修复","在 `server/src/routes/wecomAuth.js` 对企微 `auth/getuserinfo` 回包做兼容归一化:`externalUserId` 同时支持 `UserId` 与 `userid`;`externalOpenUserId` 与 `deviceId` 顺手补齐 `OpenId/openid`、`DeviceId/deviceid` 同类兼容;补单测覆盖小写 `userid` 正常识别并进入未绑定登录分支。","当腾讯回包仅带小写 `userid` 且 `errcode=0` 时,服务端不再抛 `missing_user_id`;未绑定账号应跳转到 `/login?redirect=%2Fportal&wecom=unbound`;目标单测、语法检查、Prisma validate 通过。","命令:`cd server && node --check src/routes/wecomAuth.js`;`cd server && npm test -- tests/unit/routes/wecomAuth.test.js`;`cd server && npm run prisma:validate`。业务:模拟小写 `userid` 回包,确认企微登录流程进入“未绑定”而不是 `401 AUTH_003`。","vitest","DONE","DONE","DONE","server/src/routes/wecomAuth.js|server/tests/unit/routes/wecomAuth.test.js","[""A1""]","回滚路径:回退路由与单测改动即可;不涉及 schema、env、seed。2026-05-04:`node --check`、`npm test -- tests/unit/routes/wecomAuth.test.js`(8 passed)、`npm run prisma:validate` 均已通过。"
|
||||||
|
"A3","远端部署与真机复核","将 A2 部署到 `118.89.77.218` 的 trialrun 环境,保持现有企微 env、Caddy 和数据库配置不变;你用手机企微再次打开 OA,确认故障从 `AUTH_003` 收敛为正常未绑定跳转或已绑定登录。","远端 server 已更新;`/api/auth/wecom/config` 仍为 `ready`;手机企微再次进入后,不再出现 `missing_user_id`;若账号未绑定,应回到 `/login?wecom=unbound`;若已绑定,应直接进入 OA。","命令:`ssh [email protected] 'cd /opt/hyb-oa-trialrun && docker-compose -f deploy/trialrun/docker-compose.yml --env-file deploy/trialrun/.env up -d --build server'`;`curl -k https://oa.hyinda.com/api/auth/wecom/config`;`ssh [email protected] 'docker logs --since 10m hyb-oa-trialrun-server'`。业务:你用手机企微重新点 OA 应用一次;我根据日志与最终落点确认结果。","ssh|manual","DOING","DOING","TODO","external|artifacts/r70/manual-trialrun-checklist.md|artifacts/r70/manual-trialrun-report.md","[""A2""]","回滚路径:回退到上一版 server 代码并重建容器。若修复后进入 `wecom=unbound`,说明 OAuth 已通、下一步只剩首次绑定闭环。2026-05-04:本地兼容修复与单测已完成,待重建远端 server 并用真机复核。"
|
||||||
|
@@ -216,10 +216,18 @@ async function exchangeWecomCode(config, code) {
|
|||||||
}
|
}
|
||||||
const payloadKeys = payload && typeof payload === "object" ? Object.keys(payload).sort() : [];
|
const payloadKeys = payload && typeof payload === "object" ? Object.keys(payload).sort() : [];
|
||||||
const hasStringValue = (value) => typeof value === "string" && value.trim().length > 0;
|
const hasStringValue = (value) => typeof value === "string" && value.trim().length > 0;
|
||||||
|
const pickCandidate = (...values) => {
|
||||||
|
for (const value of values) {
|
||||||
|
if (hasStringValue(value)) {
|
||||||
|
return value.trim();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
};
|
||||||
return {
|
return {
|
||||||
externalUserId: typeof payload.UserId === "string" && payload.UserId.trim() ? payload.UserId.trim() : null,
|
externalUserId: pickCandidate(payload.UserId, payload.userid),
|
||||||
externalOpenUserId: typeof payload.OpenId === "string" && payload.OpenId.trim() ? payload.OpenId.trim() : null,
|
externalOpenUserId: pickCandidate(payload.OpenId, payload.openid),
|
||||||
deviceId: typeof payload.DeviceId === "string" ? payload.DeviceId : null,
|
deviceId: pickCandidate(payload.DeviceId, payload.deviceid),
|
||||||
diagnostics: {
|
diagnostics: {
|
||||||
errcode: Number(payload.errcode ?? 0),
|
errcode: Number(payload.errcode ?? 0),
|
||||||
errmsg: typeof payload.errmsg === "string" ? payload.errmsg : null,
|
errmsg: typeof payload.errmsg === "string" ? payload.errmsg : null,
|
||||||
@@ -229,6 +237,8 @@ async function exchangeWecomCode(config, code) {
|
|||||||
userid: hasStringValue(payload.userid),
|
userid: hasStringValue(payload.userid),
|
||||||
OpenId: hasStringValue(payload.OpenId),
|
OpenId: hasStringValue(payload.OpenId),
|
||||||
openid: hasStringValue(payload.openid),
|
openid: hasStringValue(payload.openid),
|
||||||
|
DeviceId: hasStringValue(payload.DeviceId),
|
||||||
|
deviceid: hasStringValue(payload.deviceid),
|
||||||
external_userid: hasStringValue(payload.external_userid),
|
external_userid: hasStringValue(payload.external_userid),
|
||||||
user_ticket: hasStringValue(payload.user_ticket),
|
user_ticket: hasStringValue(payload.user_ticket),
|
||||||
corpid: hasStringValue(payload.corpid),
|
corpid: hasStringValue(payload.corpid),
|
||||||
|
|||||||
@@ -189,6 +189,40 @@ describe("wecom auth routes R27", () => {
|
|||||||
await app.close();
|
await app.close();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("accepts lowercase userid and redirects unbound users back to login", async () => {
|
||||||
|
mockWecomFetchWithPayload({
|
||||||
|
errcode: 0,
|
||||||
|
errmsg: "ok",
|
||||||
|
userid: "wecom-unbound-lowercase",
|
||||||
|
});
|
||||||
|
const prisma = buildPrisma({
|
||||||
|
wecomIdentityBinding: {
|
||||||
|
findUnique: vi.fn().mockResolvedValue(null),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const app = await buildApp({ prisma });
|
||||||
|
const state = signPayload({
|
||||||
|
exp: Date.now() + 60_000,
|
||||||
|
nonce: "nonce-login-lowercase-userid",
|
||||||
|
returnTo: "/portal",
|
||||||
|
mode: "login",
|
||||||
|
}, JWT_SECRET);
|
||||||
|
|
||||||
|
const response = await app.inject({
|
||||||
|
method: "GET",
|
||||||
|
url: `/api/auth/wecom/callback?state=${encodeURIComponent(state)}&code=test-code`,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.statusCode).toBe(302);
|
||||||
|
expect(response.headers.location).toBe("https://oa.hyinda.com/login?redirect=%2Fportal&wecom=unbound");
|
||||||
|
expect(prisma.wecomIdentityBinding.findUnique).toHaveBeenCalledWith({
|
||||||
|
where: { external_user_id: "wecom-unbound-lowercase" },
|
||||||
|
select: { employee_id: true, employee: { select: { id: true, status: true, token_version: true } } },
|
||||||
|
});
|
||||||
|
|
||||||
|
await app.close();
|
||||||
|
});
|
||||||
|
|
||||||
it("returns structured diagnostics when wecom callback has only open id", async () => {
|
it("returns structured diagnostics when wecom callback has only open id", async () => {
|
||||||
const logs = [];
|
const logs = [];
|
||||||
mockWecomFetchWithPayload({
|
mockWecomFetchWithPayload({
|
||||||
@@ -228,7 +262,7 @@ describe("wecom auth routes R27", () => {
|
|||||||
message: "企业微信认证失败",
|
message: "企业微信认证失败",
|
||||||
details: expect.objectContaining({
|
details: expect.objectContaining({
|
||||||
wecom: "missing_user_id",
|
wecom: "missing_user_id",
|
||||||
hasOpenId: false,
|
hasOpenId: true,
|
||||||
mode: "login",
|
mode: "login",
|
||||||
}),
|
}),
|
||||||
}));
|
}));
|
||||||
@@ -236,7 +270,7 @@ describe("wecom auth routes R27", () => {
|
|||||||
entry.msg === "wecom callback missing internal user id"
|
entry.msg === "wecom callback missing internal user id"
|
||||||
&& entry.mode === "login"
|
&& entry.mode === "login"
|
||||||
&& entry.hasUserId === false
|
&& entry.hasUserId === false
|
||||||
&& entry.hasOpenId === false
|
&& entry.hasOpenId === true
|
||||||
&& entry.hasDeviceId === false
|
&& entry.hasDeviceId === false
|
||||||
&& entry.wecomErrCode === 0
|
&& entry.wecomErrCode === 0
|
||||||
&& entry.wecomErrMsg === "ok"
|
&& entry.wecomErrMsg === "ok"
|
||||||
@@ -261,7 +295,7 @@ describe("wecom auth routes R27", () => {
|
|||||||
message: "企业微信认证失败",
|
message: "企业微信认证失败",
|
||||||
details: expect.objectContaining({
|
details: expect.objectContaining({
|
||||||
wecom: "missing_user_id",
|
wecom: "missing_user_id",
|
||||||
hasOpenId: false,
|
hasOpenId: true,
|
||||||
mode: "login",
|
mode: "login",
|
||||||
}),
|
}),
|
||||||
}));
|
}));
|
||||||
|
|||||||
在新工单中引用
屏蔽一个用户