R81 补充企微回包字段取证

这个提交包含在:
2026-05-04 18:10:55 +08:00
父节点 9dbac63744
当前提交 849fd35638
共修改 3 个文件,包含 90 行新增和 7 行删除
@@ -1,4 +1,4 @@
"ID","Title","Description","Acceptance","Test_Method","Tools","Dev_Status","Review1_Status","Regression_Status","Files","Dependencies","Notes" "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 校验。" "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` 均已通过。" "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 [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 应用 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 次以抓取真实回包特征。" "A3","远端部署与单次真机复现取证","将 A2 变更部署到 `118.89.77.218` 的 trialrun 环境,保持现有企微 env 与 Caddy 配置不变;你在手机企业微信内复现 1 次,我读取 server 日志确认腾讯真实回包形态。","远端 server 已更新为包含诊断日志的版本;`/api/auth/wecom/config` 仍为 `ready`;你完成 1 次真机复现后,日志能明确给出 `hasOpenId` 等诊断字段,并据此判断下一步是提示优化还是模型改造。","命令:`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 应用 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 容器。当前只待你再点一次手机企微入口。"
1 ID Title Description Acceptance Test_Method Tools Dev_Status Review1_Status Regression_Status Files Dependencies Notes
2 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 校验。
3 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` 均已通过。 回滚路径:回退本次路由与单测改动即可;不涉及 schema、seed、env。2026-05-04:第一层与第二层脱敏诊断均已补齐;`node --check`、`npm test -- tests/unit/routes/wecomAuth.test.js`、`npm run prisma:validate` 均已通过。
4 A3 远端部署与单次真机复现取证 将 A2 变更部署到 `118.89.77.218` 的 trialrun 环境,保持现有企微 env 与 Caddy 配置不变;你在手机企业微信内复现 1 次,我读取 server 日志确认腾讯真实回包形态。 远端 server 已更新为包含诊断日志的版本;`/api/auth/wecom/config` 仍为 `ready`;你完成 1 次真机复现后,日志能明确给出 `hasOpenId` 等诊断字段,并据此判断下一步是提示优化还是模型改造。 命令:`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 应用 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 次以抓取真实回包特征。 回滚路径:远端重新部署上一版本 server 镜像或回退工作区后重建 server 容器。若日志证实仅是非内部成员/可见范围问题,则下一轮只改错误提示,不扩模型。2026-05-04:第一次线上取证已证实 `hasUserId/hasOpenId/hasDeviceId` 全为 false;第二层脱敏日志已追加 `payloadKeys`、`fieldPresence`、`wecomErrCode`、`wecomErrMsg`,并已再次重建远端 server 容器。当前只待你再点一次手机企微入口。
+20
查看文件
@@ -214,10 +214,26 @@ async function exchangeWecomCode(config, code) {
details: { wecom: "userinfo_exchange_failed", payload }, 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 { return {
externalUserId: typeof payload.UserId === "string" && payload.UserId.trim() ? payload.UserId.trim() : null, externalUserId: typeof payload.UserId === "string" && payload.UserId.trim() ? payload.UserId.trim() : null,
externalOpenUserId: typeof payload.OpenId === "string" && payload.OpenId.trim() ? payload.OpenId.trim() : null, externalOpenUserId: typeof payload.OpenId === "string" && payload.OpenId.trim() ? payload.OpenId.trim() : null,
deviceId: typeof payload.DeviceId === "string" ? payload.DeviceId : 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, hasUserId: false,
hasOpenId: Boolean(profile.externalOpenUserId), hasOpenId: Boolean(profile.externalOpenUserId),
hasDeviceId: Boolean(profile.deviceId), 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", "wecom callback missing internal user id",
); );
+68 -5
查看文件
@@ -31,8 +31,8 @@ function buildPrisma(overrides = {}) {
}; };
} }
async function buildApp({ config = {}, prisma = buildPrisma() } = {}) { async function buildApp({ config = {}, prisma = buildPrisma(), fastifyOptions = {} } = {}) {
const app = Fastify(); const app = Fastify(fastifyOptions);
await app.register(fastifyCookie); await app.register(fastifyCookie);
app.decorate("config", { app.decorate("config", {
appOrigin: "https://oa.hyinda.com", 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(() => { afterEach(() => {
global.fetch = originalFetch; global.fetch = originalFetch;
vi.restoreAllMocks(); vi.restoreAllMocks();
@@ -178,8 +190,26 @@ describe("wecom auth routes R27", () => {
}); });
it("returns structured diagnostics when wecom callback has only open id", async () => { it("returns structured diagnostics when wecom callback has only open id", async () => {
mockWecomFetch({ userId: "", openId: "wecom-open-only" }); const logs = [];
const app = await buildApp(); 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({ const state = signPayload({
exp: Date.now() + 60_000, exp: Date.now() + 60_000,
nonce: "nonce-openid-only", nonce: "nonce-openid-only",
@@ -198,7 +228,40 @@ describe("wecom auth routes R27", () => {
message: "企业微信认证失败", message: "企业微信认证失败",
details: expect.objectContaining({ details: expect.objectContaining({
wecom: "missing_user_id", 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", mode: "login",
}), }),
})); }));