diff --git a/issues/2026-05-04_17-50-36-r81-wecom-callback-identity-diagnosis.csv b/issues/2026-05-04_17-50-36-r81-wecom-callback-identity-diagnosis.csv new file mode 100644 index 00000000..ed48cd75 --- /dev/null +++ b/issues/2026-05-04_17-50-36-r81-wecom-callback-identity-diagnosis.csv @@ -0,0 +1,4 @@ +"ID","Title","Description","Acceptance","Test_Method","Tools","Dev_Status","Review1_Status","Regression_Status","Files","Dependencies","Notes" +"A1","R81 执行合同建档与诊断范围冻结","为企微真机回调 `AUTH_003 / missing_user_id` 建立独立执行合同。本轮范围仅覆盖:后端最小安全诊断日志、单测、远端部署、单次真机复现取证;明确不改 `wecom_identity_bindings` 模型,不把 `OpenId` 直接接入登录主键。","CSV 列完整且通过校验;A2-A3 依赖顺序清晰;Notes 明确本轮不改绑定模型、不引入迁移、不改变现有登录语义。","命令:`python3 /home/shunli/.codex/skills/plan/scripts/validate_issues_csv.py issues/2026-05-04_17-50-36-r81-wecom-callback-identity-diagnosis.csv`。业务:确认本轮只承接“腾讯回包身份字段取证与最小诊断”,不混入企微绑定模型改造。","manual","DONE","DONE","DONE","issues/2026-05-04_17-50-36-r81-wecom-callback-identity-diagnosis.csv","[]","回滚路径:删除本 CSV,不影响运行时。真实腾讯密钥继续只保留在线上 env,不入仓。2026-05-04:R81 合同已写入并通过 validate_issues_csv.py 校验。" +"A2","企微回调缺失 UserId 的最小诊断改动","在 `server/src/routes/wecomAuth.js` 为 `missing_user_id` 分支补充安全诊断:记录 `requestId`、`mode`、`hasUserId`、`hasOpenId`、`hasDeviceId`,不记录 token/secret/完整敏感回包;补 1 条单测覆盖 OpenId-only 响应。","OpenId-only 或 UserId 缺失时,服务端能留下足够判因的安全日志;接口仍返回 `AUTH_003`;单测可证明 `details` 带有 `hasOpenId/mode` 提示;`node --check` 与目标单测通过。","命令:`cd server && node --check src/routes/wecomAuth.js`;`cd server && npm test -- tests/unit/routes/wecomAuth.test.js`。业务:模拟企微仅回 `OpenId` 的场景,确认接口返回 `401 AUTH_003` 且错误细节明确为 `missing_user_id`。","vitest","DONE","DONE","DONE","server/src/routes/wecomAuth.js|server/tests/unit/routes/wecomAuth.test.js","[""A1""]","回滚路径:回退本次路由与单测改动即可;不涉及 schema、seed、env。2026-05-04:`node --check`、`npm test -- tests/unit/routes/wecomAuth.test.js`、`npm run prisma:validate` 均已通过。" +"A3","远端部署与单次真机复现取证","将 A2 变更部署到 `118.89.77.218` 的 trialrun 环境,保持现有企微 env 与 Caddy 配置不变;你在手机企业微信内复现 1 次,我读取 server 日志确认腾讯真实回包形态。","远端 server 已更新为包含诊断日志的版本;`/api/auth/wecom/config` 仍为 `ready`;你完成 1 次真机复现后,日志能明确给出 `hasOpenId` 等诊断字段,并据此判断下一步是提示优化还是模型改造。","命令:`ssh deploy@118.89.77.218 '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 root@118.89.77.218 'docker logs --since 10m hyb-oa-trialrun-server'`。业务:你用手机企业微信重新打开 OA 应用 1 次;若再次失败,记录页面时间点,我同步抓取对应日志。","ssh|manual","DOING","DOING","TODO","deploy/trialrun/deploy.sh|external|artifacts/r70/manual-trialrun-checklist.md|artifacts/r70/manual-trialrun-report.md","[""A2""]","回滚路径:远端重新部署上一版本 server 镜像或回退工作区后重建 server 容器。若日志证实仅是非内部成员/可见范围问题,则下一轮只改错误提示,不扩模型。2026-05-04:已用 root 重建远端 server 容器;`/ready`、`/api/auth/wecom/config`、`/api/auth/wecom/login` 复核通过。待你用手机复现 1 次以抓取真实回包特征。" diff --git a/server/src/routes/wecomAuth.js b/server/src/routes/wecomAuth.js index d8f34860..58279e4d 100644 --- a/server/src/routes/wecomAuth.js +++ b/server/src/routes/wecomAuth.js @@ -363,11 +363,25 @@ async function wecomAuthRoutes(fastify) { const returnTo = sanitizeReturnTo(ctx.returnTo, fastify.config, request); const profile = await exchangeWecomCode(fastify.config, code); if (!profile.externalUserId) { + request.log.warn( + { + requestId: request.id, + mode, + hasUserId: false, + hasOpenId: Boolean(profile.externalOpenUserId), + hasDeviceId: Boolean(profile.deviceId), + }, + "wecom callback missing internal user id", + ); throw new AppError({ code: "AUTH_003", message: "企业微信认证失败", statusCode: 401, - details: { wecom: "missing_user_id" }, + details: { + wecom: "missing_user_id", + hasOpenId: Boolean(profile.externalOpenUserId), + mode, + }, }); } diff --git a/server/tests/unit/routes/wecomAuth.test.js b/server/tests/unit/routes/wecomAuth.test.js index 094898bd..4acdb19a 100644 --- a/server/tests/unit/routes/wecomAuth.test.js +++ b/server/tests/unit/routes/wecomAuth.test.js @@ -177,6 +177,35 @@ describe("wecom auth routes R27", () => { await app.close(); }); + it("returns structured diagnostics when wecom callback has only open id", async () => { + mockWecomFetch({ userId: "", openId: "wecom-open-only" }); + const app = await buildApp(); + const state = signPayload({ + exp: Date.now() + 60_000, + nonce: "nonce-openid-only", + 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(401); + expect(response.json().error).toEqual(expect.objectContaining({ + code: "AUTH_003", + message: "企业微信认证失败", + details: expect.objectContaining({ + wecom: "missing_user_id", + hasOpenId: true, + mode: "login", + }), + })); + + await app.close(); + }); + it("creates bind audit log on successful wecom bind callback", async () => { mockWecomFetch({ userId: "wecom-bind-1" }); const prisma = buildPrisma();