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 index ed48cd75..192f6352 100644 --- 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 @@ -1,4 +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 次以抓取真实回包特征。" +"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:第一次线上取证已证实 `hasUserId/hasOpenId/hasDeviceId` 全为 false;第二层脱敏日志已追加 `payloadKeys`、`fieldPresence`、`wecomErrCode`、`wecomErrMsg`,并已再次重建远端 server 容器。当前只待你再点一次手机企微入口。" diff --git a/server/src/routes/wecomAuth.js b/server/src/routes/wecomAuth.js index 58279e4d..7f18ed70 100644 --- a/server/src/routes/wecomAuth.js +++ b/server/src/routes/wecomAuth.js @@ -214,10 +214,26 @@ async function exchangeWecomCode(config, code) { details: { wecom: "userinfo_exchange_failed", payload }, }); } + const payloadKeys = payload && typeof payload === "object" ? Object.keys(payload).sort() : []; + const hasStringValue = (value) => typeof value === "string" && value.trim().length > 0; return { externalUserId: typeof payload.UserId === "string" && payload.UserId.trim() ? payload.UserId.trim() : null, externalOpenUserId: typeof payload.OpenId === "string" && payload.OpenId.trim() ? payload.OpenId.trim() : null, deviceId: typeof payload.DeviceId === "string" ? payload.DeviceId : null, + diagnostics: { + errcode: Number(payload.errcode ?? 0), + errmsg: typeof payload.errmsg === "string" ? payload.errmsg : null, + payloadKeys, + fieldPresence: { + UserId: hasStringValue(payload.UserId), + userid: hasStringValue(payload.userid), + OpenId: hasStringValue(payload.OpenId), + openid: hasStringValue(payload.openid), + external_userid: hasStringValue(payload.external_userid), + user_ticket: hasStringValue(payload.user_ticket), + corpid: hasStringValue(payload.corpid), + }, + }, }; } @@ -370,6 +386,10 @@ async function wecomAuthRoutes(fastify) { hasUserId: false, hasOpenId: Boolean(profile.externalOpenUserId), hasDeviceId: Boolean(profile.deviceId), + wecomErrCode: profile.diagnostics?.errcode ?? null, + wecomErrMsg: profile.diagnostics?.errmsg ?? null, + payloadKeys: profile.diagnostics?.payloadKeys ?? [], + fieldPresence: profile.diagnostics?.fieldPresence ?? {}, }, "wecom callback missing internal user id", ); diff --git a/server/tests/unit/routes/wecomAuth.test.js b/server/tests/unit/routes/wecomAuth.test.js index 4acdb19a..010ba18f 100644 --- a/server/tests/unit/routes/wecomAuth.test.js +++ b/server/tests/unit/routes/wecomAuth.test.js @@ -31,8 +31,8 @@ function buildPrisma(overrides = {}) { }; } -async function buildApp({ config = {}, prisma = buildPrisma() } = {}) { - const app = Fastify(); +async function buildApp({ config = {}, prisma = buildPrisma(), fastifyOptions = {} } = {}) { + const app = Fastify(fastifyOptions); await app.register(fastifyCookie); app.decorate("config", { appOrigin: "https://oa.hyinda.com", @@ -87,6 +87,18 @@ function mockWecomFetch({ userId = "wecom-user-1", openId = "wecom-open-1" } = { }); } +function mockWecomFetchWithPayload(payload) { + global.fetch = vi.fn() + .mockResolvedValueOnce({ + ok: true, + json: async () => ({ errcode: 0, access_token: "access-token" }), + }) + .mockResolvedValueOnce({ + ok: true, + json: async () => payload, + }); +} + afterEach(() => { global.fetch = originalFetch; vi.restoreAllMocks(); @@ -178,8 +190,26 @@ describe("wecom auth routes R27", () => { }); it("returns structured diagnostics when wecom callback has only open id", async () => { - mockWecomFetch({ userId: "", openId: "wecom-open-only" }); - const app = await buildApp(); + const logs = []; + mockWecomFetchWithPayload({ + errcode: 0, + errmsg: "ok", + openid: "lowercase-openid-value", + user_ticket: "ticket-value", + corpid: "corp-id-value", + }); + const app = await buildApp({ + fastifyOptions: { + logger: { + level: "warn", + stream: { + write(message) { + logs.push(JSON.parse(message)); + }, + }, + }, + }, + }); const state = signPayload({ exp: Date.now() + 60_000, nonce: "nonce-openid-only", @@ -198,7 +228,40 @@ describe("wecom auth routes R27", () => { message: "企业微信认证失败", details: expect.objectContaining({ wecom: "missing_user_id", - hasOpenId: true, + hasOpenId: false, + mode: "login", + }), + })); + expect(logs.some((entry) => ( + entry.msg === "wecom callback missing internal user id" + && entry.mode === "login" + && entry.hasUserId === false + && entry.hasOpenId === false + && entry.hasDeviceId === false + && entry.wecomErrCode === 0 + && entry.wecomErrMsg === "ok" + && Array.isArray(entry.payloadKeys) + && entry.payloadKeys.join(",") === "corpid,errcode,errmsg,openid,user_ticket" + && entry.fieldPresence?.UserId === false + && entry.fieldPresence?.userid === false + && entry.fieldPresence?.OpenId === false + && entry.fieldPresence?.openid === true + && entry.fieldPresence?.external_userid === false + && entry.fieldPresence?.user_ticket === true + && entry.fieldPresence?.corpid === true + ))).toBe(true); + + expect(logs.some((entry) => ( + entry.msg === "wecom callback missing internal user id" + && typeof entry.requestId === "string" + ))).toBe(true); + + expect(response.json().error).toEqual(expect.objectContaining({ + code: "AUTH_003", + message: "企业微信认证失败", + details: expect.objectContaining({ + wecom: "missing_user_id", + hasOpenId: false, mode: "login", }), }));