Просмотр исходного кода

feat: integrate verified WeCom employee names and unify attribution

pengzhikang 1 день назад
Родитель
Сommit
b4dffed46b

+ 35 - 1
deploy/five-views-files-20261103.json

@@ -1,6 +1,6 @@
 {
   "version": "ksf.five-views.publish.v1",
-  "release": "ksf.workshop-board.20261103.v3",
+  "release": "ksf.workshop-board.20261103.v4",
   "homepageSha256": "dfeb6a6d0b63e8e1190f9308bf6619a5ca523a5a2f91af5f7ca5ad5fc9642db3",
   "files": [
     {
@@ -230,6 +230,40 @@
       "priorNormalizedSha256": [
         "cbdcc7e9626a583ab26d1db8466d80bd06d2ee12ccb38892fd912bdc750cf61c"
       ]
+    },
+    {
+      "path": "src/core/kfs/build.js",
+      "bytes": 11744,
+      "sha256": "05dd6acd7f5f0d89dd4b29a1eccd217685b7aacc56cbb4357e948674491a081a",
+      "allowMissing": false,
+      "priorNormalizedSha256": [
+        "6fb3c711c09c27d2505ca8bdc04392d38c80287901743f6d73c90b09c3f56df3"
+      ]
+    },
+    {
+      "path": "src/core/kfs/employee.js",
+      "bytes": 3057,
+      "sha256": "d2b312ac7ae6c749279a9c04c9e87e697b86c269e503ab3009c6e1b69c852eb1",
+      "allowMissing": true,
+      "priorNormalizedSha256": []
+    },
+    {
+      "path": "src/core/opportunity/opportunity.js",
+      "bytes": 9452,
+      "sha256": "14c198eefc24883d75dd61a05cc2245c44da2f8cb8acaefade8995d7878616a8",
+      "allowMissing": false,
+      "priorNormalizedSha256": [
+        "445f968aa5b6d2e3f0eda9fcfa1cde7eb7a65fcfdb7f39c81a4b4f8ff2eb559a"
+      ]
+    },
+    {
+      "path": "src/pages/view-members.js",
+      "bytes": 10164,
+      "sha256": "c31e3a4d09f7b519571c914dba75c5cbef995457ba7fd221c7eae610ea9eb9e3",
+      "allowMissing": false,
+      "priorNormalizedSha256": [
+        "95510ecc92b4751f7666287d5310088cb3f87d8250fbb53e3d58de8dcb9270ff"
+      ]
     }
   ],
   "preserved": [

+ 26 - 1
deploy/five-views-production-baseline-20261103.json

@@ -252,6 +252,31 @@
       "contentType": "application/javascript; charset=utf-8",
       "present": true,
       "at": "2026-11-03T11:01:17.299Z"
+    },
+    {
+      "path": "src/core/kfs/build.js",
+      "bytes": 12186,
+      "rawSha256": "6fb3c711c09c27d2505ca8bdc04392d38c80287901743f6d73c90b09c3f56df3",
+      "normalizedSha256": "6fb3c711c09c27d2505ca8bdc04392d38c80287901743f6d73c90b09c3f56df3",
+      "contentType": "application/javascript; charset=utf-8",
+      "present": true
+    },
+    {
+      "path": "src/core/opportunity/opportunity.js",
+      "bytes": 9214,
+      "rawSha256": "445f968aa5b6d2e3f0eda9fcfa1cde7eb7a65fcfdb7f39c81a4b4f8ff2eb559a",
+      "normalizedSha256": "445f968aa5b6d2e3f0eda9fcfa1cde7eb7a65fcfdb7f39c81a4b4f8ff2eb559a",
+      "contentType": "application/javascript; charset=utf-8",
+      "present": true
+    },
+    {
+      "path": "src/pages/view-members.js",
+      "bytes": 9596,
+      "rawSha256": "95510ecc92b4751f7666287d5310088cb3f87d8250fbb53e3d58de8dcb9270ff",
+      "normalizedSha256": "95510ecc92b4751f7666287d5310088cb3f87d8250fbb53e3d58de8dcb9270ff",
+      "contentType": "application/javascript; charset=utf-8",
+      "present": true
     }
-  ]
+  ],
+  "identityStaticCheckedAt": "2026-11-03T11:38:39.142Z"
 }

+ 6 - 6
docs/showcase/deploy.md

@@ -1,12 +1,12 @@
 # 运维001:产品创新看板与系统介绍部署
 
-本包为移除额外导航入口和固定端数表述后的v3版本,包含中文裁切修复,替代此前r1、r2包,勿先后叠加发布。本包尚未部署。只更新30个静态文件,不执行业务函数、不改数据库、首页、nginx、Schema、ACL、Skill或wrapper,不需要OBS凭据、服务密钥或重启。
+本包v4包含企业微信姓名展示、成员归一、中文裁切修复及导航纠正,替代此前r1、r2、r3包,勿先后叠加发布。本包尚未部署。本节只更新34个静态文件;企业微信后端和首次同步按同包`docs/wecom-identity-integration-20261103.md`执行。静态发布不改数据库、首页、nginx、Schema、ACL、Skill或wrapper,不需要OBS凭据或重启。
 
 ## 包与目录
 
-发布版本:`ksf.workshop-board.20261103.v3`。脚本需要Node.js 20或以上,无npm安装步骤。
+发布版本:`ksf.workshop-board.20261103.v4`。脚本需要Node.js 20或以上,无npm安装步骤。
 
-将zip解压到站点以外的新目录,例如`/opt/ksf-workshop-board-20261103-r3/`。不要解压到站点内。进入解包根目录,执行`sha256sum -c FILES.sha256`,必须全部通过。
+将zip解压到站点以外的新目录,例如`/opt/ksf-workshop-board-20261103-r4/`。不要解压到站点内。进入解包根目录,执行`sha256sum -c FILES.sha256`,必须全部通过。
 
 此前维护人核实的站点为`/opt/kangshifu-web`,执行前再次从生效的nginx配置确认真实root。若不同,向脚本传入实际绝对路径,不猜目录。
 
@@ -17,7 +17,7 @@ node scripts/publish-five-views.mjs --target-dir /opt/kangshifu-web
 node scripts/publish-five-views.mjs --target-dir /opt/kangshifu-web --apply
 ```
 
-第一条默认只读,核对`filesChecked=30`、changes清单与manifest一致。第二条会在站点外创建独立备份目录,先备份全部旧文件,再替换。留存两次完整stdout/stderr/exitcode。任何失败即停,不覆盖未知版本、不改manifest绕过检查。
+第一条默认只读,核对`filesChecked=34`、changes清单与manifest一致。第二条会在站点外创建独立备份目录,先备份全部旧文件,再替换。留存两次完整stdout/stderr/exitcode。任何失败即停,不覆盖未知版本、不改manifest绕过检查。
 
 脚本要求首页SHA256为`dfeb6a6d0b63e8e1190f9308bf6619a5ca523a5a2f91af5f7ca5ad5fc9642db3`;保留既有公开机会卡、评分、报告与证据修复的五个文件指纹。既有共享文件接受已核验的LF归一化旧指纹;包内新文件及执行后文件使用原始字节SHA256精确核对。
 
@@ -25,14 +25,14 @@ node scripts/publish-five-views.mjs --target-dir /opt/kangshifu-web --apply
 
 ## 外部HTTPS与浏览器验收
 
-1. 按manifest对30个文件做HTTPS GET并计算原始SHA256;JSON应为JSON、JS应为JS,不能把SPA的200 HTML兜底当作文件成功。首页哈希保持不变。
+1. 按manifest对34个文件做HTTPS GET并计算原始SHA256;JSON应为JSON、JS应为JS,不能把SPA的200 HTML兜底当作文件成功。首页哈希保持不变。
 2. 打开并刷新`/showcase`与其五个介绍页:`/showcase/leadership`、`/showcase/research`、`/showcase/collaboration`、`/showcase/validation`、`/showcase/classroom`。五张图与系统入口均可用。
 3. 打开`/cockpit`、`/intel`、`/coop`、`/review`、`/class`。驾驶舱12项目/36人/24卡/44报告/3受阻;情报页12,000声音/720商品/360引文;课堂18单作业。基线334条,扩展16,446条另计。
 4. 用模拟卡验证本机状态和评分、同名重评,打开来源、报告、四条价格,刷新保留,重置后只清本机演示修改。用浏览器网络日志确认新路由没有生产业务请求。
 5. 检查报告中心标题与五类名称的上下笔画完整,点击分类后页标题正确,再检查P01/P03/P10动线、小组/成员筛选、分页、手机布局和旧看板入口。旧报告错误链接应自动恢复;原公开卡详情应正常。对生产状态/评分只检查入口与合同,未经另外指示不为验收改真实记录。
 6. 现有看板桌面侧栏和手机导航保持原范围,无新增独立演示集合入口;介绍页和交互页按功能命名。
 
-本机已完成69项Node测试、13项静态检查及115项Edge验收;详见`docs/showcase/validation-result.json`。本机回归中的旧公开操作由浏览器拦截并本地回放,生产请求实际发送数为0。本机验证不等于生产验收:部署回执和生产浏览器验收后才能记录`browserVerified=true`。
+本机验证结果详见`docs/showcase/validation-result.json`。本机回归中的旧公开操作由浏览器拦截并本地回放,生产请求实际发送数为0。本机验证不等于生产验收:部署回执和生产浏览器验收后才能记录`browserVerified=true`。
 
 ## 回退
 

+ 8 - 6
docs/showcase/validation-result.json

@@ -1,8 +1,8 @@
 {
-  "version": "ksf.workshop-board.validation.20261103.v3",
+  "version": "ksf.workshop-board.validation.20261103.v4",
   "nodeTests": {
-    "total": 69,
-    "passed": 69,
+    "total": 80,
+    "passed": 80,
     "failed": 0
   },
   "staticChecks": {
@@ -11,8 +11,8 @@
   },
   "browser": {
     "engine": "Microsoft Edge headless",
-    "checks": 115,
-    "passed": 115,
+    "checks": 119,
+    "passed": 119,
     "errors": [],
     "productionRequestsSent": 0,
     "externalTrafficIntercepted": true,
@@ -26,7 +26,9 @@
     ],
     "chineseGlyphClipping": 0,
     "extraNavigationEntryRemoved": true,
-    "evidence": "work/showcase-browser-1793704627554/acceptance.json"
+    "evidence": "work/showcase-browser-1793706091181/acceptance.json",
+    "wecomFixtureVerified": true,
+    "productionWecomVerified": false
   },
   "scope": {
     "baseline": 334,

+ 61 - 0
docs/wecom-identity-integration-20261103.md

@@ -0,0 +1,61 @@
+# 运维001:企业微信姓名接入与成员归一
+
+用户已要求接入并显示企业微信真实姓名和可识别信息。代码、测试及发布准备已完成;本机没有通讯录应用凭据、已核实的发送者映射或远程终端,尚未实际取得姓名或部署。由运维001按现有职责发布,不把本机夹具验收写成生产实名接通。
+
+## 已知现状与显示行为
+
+生产公开接口于2026-11-03回读:`2InAdyzimm`(K轮)、`btrFqkz0yw`(真实资料轮)来自同一已登记员工档案,展示名均为员工待绑定。括号中的e19422/921104是工作坊参与键尾码,不是工号。
+
+新代码按稳定企业成员键归并跨工作坊的会话、成果、机会卡和项目,同名不同人仍分开;姓名从当前通讯录映射显示,保留原始归属快照与历史正文。公开成员页显示:通讯录姓名、可取得的显示名/部门/职务、企业名称(维护人配置)及同步时间;未知项不编造。通讯录姓名仅表示接口返回的成员姓名,不冒称身份证实名核验。
+
+首次同步前,发送者仍按稳定账号归并,已取得显示名则用显示名,否则标“姓名待同步”。不再用工作坊尾码冒充姓名。手机号、邮箱、原始UserID、机器人ID、应用secret、access_token均不进入公开响应。
+
+## 上线所缺配置(由企业管理员/运维提供,勿贴进聊天)
+
+1. 同一企业有成员读取权限的自建应用:真实CorpID、Secret、可见范围和API可信IP。需要读取本次测试员工;部门接口无权限时姓名仍可接入、部门留空。
+2. 从当前适配器私有档案取得真实botId和from.userid。核实它与通讯录成员UserID的对应关系,不用前缀猜测。默认`senderIdType=mapped`,维护人把已核实的映射写在私有配置;只有平台合同和真实对照证明确为通讯录UserID时才用`directory_userid`。不同机器人分别核实。
+3. 平台连接配置:沿用已核验应用的AppID和受控管理凭据。包内`platform.example.json`及`directory.example.json`的null是故意不可运行的占位。实际配置均放包外,0600、受限属主。
+
+本次生产只读核查,KsfRuntimeConfig.value仅有release/signingSecret/employeeAccountsProvisioned,没有wecomDirectory或wecomEmployees;本机也没有企微凭据。不能据此声称已连通通讯录。
+
+## 服务端发布与首次同步
+
+先验证包根`FILES.sha256`,再设置环境变量`KSF_PLATFORM_CONFIG`指向包外0600的实际platform JSON。命令行不带Secret。Node.js 20或以上。
+
+```sh
+node server/identity/manage.mjs deploy
+node server/identity/manage.mjs deploy --apply --receipt-dir /opt/data/ksf-identity-private/receipts
+node server/identity/manage.mjs configure --file /opt/data/ksf-identity-private/directory.json
+node server/identity/manage.mjs configure --file /opt/data/ksf-identity-private/directory.json --apply --receipt-dir /opt/data/ksf-identity-private/receipts
+node server/identity/manage.mjs sync --sessions 2InAdyzimm,btrFqkz0yw
+node server/identity/manage.mjs sync --sessions 2InAdyzimm,btrFqkz0yw --apply --request-id KSF:wecom-identity:initial:20261103:v1
+```
+
+每步失败即停,保留stdout/stderr/exitcode和私有回执;未知写结果先核对,不自动换键。dry同步会调用企微读取接口,零数据库写入;apply只更新KsfRuntimeConfig.value.wecomEmployees及一条成功操作审计,不重存业务报告或改工作坊进度。配置修改先留私有备份,再合并当前运行配置;不覆盖signingSecret和其他设置。
+
+发布仅更新`ksfGetPublicBoard`(对象inPfC51rvn)并新增`ksfSyncWecomEmployee`。基于现网Function.code原样叠加本次身份处理,不把本地旧版整套后端覆盖上去。原公开函数基线SHA256须为`266a84fc00de4634f0efb59ddd7a7aaa4e86f24ee310831ea731ea7c133040c9`;未知版本停止。未改StartSession、Skill、wrapper、Schema或ACL。
+
+同步函数须具备identity:write或管理权限。它只接受会话编号,由服务端读取私有账号并查询企微;不接收调用者填写的姓名、部门或UserID。公开查询只读已同步档案,不公开调用企微接口。
+
+## 持续同步和浏览器验收
+
+首次人工核对通过后,把`node /实际包目录/server/identity/scheduled-sync.mjs`接到维护人现有的每10分钟计划任务,继承受限KSF_PLATFORM_CONFIG。脚本发现已有公开企微会话,按分钟业务键执行一次同步;错误不自动重试。后续新会话在下一次成功同步后显示通讯录姓名,浏览器原轮询会刷新。若要求新消息即时同步,另接适配器注册成功钩子;本包不宣称实现即时推送。
+
+前端使用同包`docs/showcase/deploy.md`发布34个静态文件。包替代此前尚未发布的r1/r2/r3,不叠加旧包。检查成员页两个原测试会话归在同一个成员名下,9+6=15份成果、两个项目保留;成员的姓名与通讯录只读结果一致,部门/职务以实际返回为准。核对机会卡和报告上传人同名、原报告正文/ID/数量不变。
+
+生产验收需记录目录查询成功、同步审计、公开响应不含账号/Secret、桌面及手机显示、同名不同人、另一个发送者不会串身份。没有生产浏览器验收前`browserVerified=false`。
+
+## 回退
+
+用部署stdout返回的receipt绝对路径:
+
+```sh
+node server/identity/manage.mjs rollback --receipt /实际私有目录/identity-deploy-实际编号.json
+node server/identity/manage.mjs rollback --receipt /实际私有目录/identity-deploy-实际编号.json --apply
+```
+
+工具先校验两个函数的当前版本和旧代码哈希,再恢复原公开函数、删除本次新增同步函数;遇并发修改停止。中途失败也保留prepared回执,未改的原函数无需恢复;若新增请求结果未知且未取得ID,先按函数名核对,勿盲删。静态文件用其独立backup/journal回退。目录配置的私有备份由维护人核对后恢复对应字段,不覆盖其他并发设置;停止计划任务。业务原记录一直未迁移,无需重跑工作坊。
+
+## 本机验证
+
+夹具覆盖有权限/无权限、错映射、停用成员、同人跨工作坊、同名不同人、服务端显示叠加、无公开凭据、受控同步、发布/重复/回退与并发保护。浏览器使用“测试成员甲/测试产品部”合成资料验证桌面与390px手机显示,全部外部请求拦截,未调用真实企微接口。

+ 10 - 0
scripts/build-five-views-package.mjs

@@ -3,6 +3,7 @@ import path from 'node:path';
 import crypto from 'node:crypto';
 import { fileURLToPath } from 'node:url';
 import { FILES, RELEASE } from './publish-five-views.mjs';
+import { buildIdentityFunctions } from '../server/identity/build-release.mjs';
 
 const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
 const sha = (b) => crypto.createHash('sha256').update(b).digest('hex');
@@ -32,6 +33,15 @@ if (!process.argv.includes('--manifest-only')) {
   for (const file of FILES) copy(file, 'payload/' + file);
   for (const file of ['scripts/publish-five-views.mjs', 'deploy/five-views-files-20261103.json', 'deploy/five-views-production-baseline-20261103.json',
     'docs/showcase/prd.md', 'docs/showcase/schema.md', 'docs/showcase/api.md', 'docs/showcase/design.md', 'docs/showcase/test.md', 'docs/showcase/deploy.md', 'docs/showcase/validation-result.json']) copy(file);
+  const identityIndex = process.argv.indexOf('--identity-base');
+  if (identityIndex >= 0) {
+    const functions = buildIdentityFunctions(JSON.parse(fs.readFileSync(process.argv[identityIndex + 1], 'utf8')));
+    const dist = path.join(out, 'server/identity/dist'); fs.mkdirSync(dist, { recursive: true });
+    for (const entry of functions) fs.writeFileSync(path.join(dist, entry.name + '.js'), entry.code);
+    fs.writeFileSync(path.join(dist, 'manifest.json'), JSON.stringify({ version: 'ksf.wecom-identity.20261103.v1', functions: functions.map(({ code, ...rest }) => rest) }, null, 2));
+    for (const file of ['wecom-directory.mjs', 'employee-handlers.mjs', 'build-release.mjs', 'manage.mjs', 'scheduled-sync.mjs', 'directory.example.json', 'platform.example.json']) copy('server/identity/' + file);
+    copy('docs/wecom-identity-integration-20261103.md');
+  }
   const all = fs.readdirSync(out, { recursive: true }).filter((p) => fs.statSync(path.join(out, p)).isFile()).sort();
   fs.writeFileSync(path.join(out, 'FILES.sha256'), all.map((p) => sha(fs.readFileSync(path.join(out, p))) + '  ' + p.replaceAll('\\', '/')).join('\n') + '\n');
   console.log(JSON.stringify({ ok: true, release: RELEASE, out, payloadFiles: FILES.length, files: all.length + 1, deployed: false }));

+ 2 - 1
scripts/publish-five-views.mjs

@@ -3,7 +3,7 @@ import path from 'node:path';
 import crypto from 'node:crypto';
 import { fileURLToPath } from 'node:url';
 
-export const RELEASE = 'ksf.workshop-board.20261103.v3';
+export const RELEASE = 'ksf.workshop-board.20261103.v4';
 export const FILES = Object.freeze([
   'src/layout/kf-app.js', 'src/layout/kf-nav.js', 'src/layout/sample-shell.js',
   'src/utils/router.js', 'src/utils/http.js', 'src/services/workshop-data.js',
@@ -17,6 +17,7 @@ export const FILES = Object.freeze([
   'src/assets/showcase/collaboration.png', 'src/assets/showcase/validation.png',
   'src/assets/showcase/classroom.png', 'src/assets/showcase/preview-seed.json', 'src/assets/showcase/demo-extension.json',
   'src/styles/tokens.js', 'src/layout/kf-topbar.js', 'src/pages/view-reports.js', 'src/pages/view-report-type.js',
+  'src/core/kfs/build.js', 'src/core/kfs/employee.js', 'src/core/opportunity/opportunity.js', 'src/pages/view-members.js',
 ]);
 const PRESERVED = ['src/services/board-service.js', 'src/components/kf-opportunity-card.js',
   'src/pages/view-pool.js', 'src/core/report.js', 'src/core/evidence/chain.js'];

+ 11 - 0
scripts/verify-showcase-browser.mjs

@@ -70,6 +70,9 @@ try {
   fixture.result.data.artifact.push({ objectId: 'fixture-report', title: '原有报告回归', stageCode: 'S4', artifactType: 'competitor_report', report: { title: '原有报告回归', chapters: [{ id: 'scope', title: '核对原文', sections: [{ kind: 'para', text: '公开报告阅读回归正文。' }] }], evidence: {} } });
   for (const type of ['category_report', 'voc_report', 'pain_list', 'concept']) fixture.result.data.artifact.push({ objectId: 'fixture-' + type, artifactType: type, title: '中文标题完整显示与多行排版验证', stageCode: 'S1', rawText: '本机回放排版测试资料。', createdAt: '2026-11-03T00:00:00.000Z' });
   fixture.result.data.opportunity.push({ objectId: 'fixture-opportunity', title: '原有机会卡回归', status: 'candidate', linkedArtifacts: ['fixture-report'], scores: [], updatedAt: '2026-11-03T00:00:00.000Z' });
+  const fixtureEmployee = { key: 'employee:' + 'a'.repeat(64), name: '测试成员甲', displayName: '测试昵称', organizationName: '测试企业', departmentNames: ['测试产品部'], position: '产品经理', source: 'wecom_directory', nameVerified: true, verifiedAt: '2026-11-03T00:00:00Z' };
+  fixture.result.data.session.push(...['one', 'two'].map((id) => ({ objectId: 'identity-session-' + id, studentName: '旧占位身份', employee: fixtureEmployee, meta: { participantKey: 'workshop-participant-' + id } })));
+  Object.assign(fixture.result.data.artifact[0], { ownerSession: 'identity-session-one', ownerName: '旧占位身份' });
   socket.addEventListener('message', ({ data }) => {
     const m = JSON.parse(data);
     if (m.id && pending.has(m.id)) {
@@ -265,6 +268,14 @@ try {
   check('existing live route resumes loading', forbidden.some((r) => r.body.includes('ksfGetPublicBoard')));
   check('existing navigation preserves its original scope without an extra demonstration entry', await evaluate(`(()=>{const root=document.querySelector('kf-app').shadowRoot.querySelector('kf-nav').shadowRoot;return !root.querySelector('a[data-path="/showcase"]')&&!root.querySelector('.showcase-entry')&&root.querySelectorAll('.tabbar a').length===5&&[...root.querySelectorAll('a[data-path]')].every(a=>a.href.includes('/preview-test/'));})()`));
   const legacyNavigate = async (route) => { await evaluate(`import(${JSON.stringify(origin + mount + 'src/utils/router.js')}).then(m=>m.navigate(${JSON.stringify(route)}))`); await pause(450); };
+  for (const width of [1440, 390]) {
+    await cdp('Emulation.setDeviceMetricsOverride', { width, height: 1000, deviceScaleFactor: 1, mobile: width < 768 });
+    await legacyNavigate('/members');
+    const identityInfo = await evaluate(`(()=>{const page=document.querySelector('kf-app').shadowRoot.querySelector('view-members');const rows=[...page.shadowRoot.querySelectorAll('[data-member]')];return {names:rows.map(r=>r.dataset.member),text:page.shadowRoot.textContent,width:document.documentElement.scrollWidth,viewport:innerWidth};})()`);
+    check(`employee ${width}px: same member appears once across workshops, with available directory fields`, identityInfo.names.length === 1 && identityInfo.names[0] === '测试成员甲' && ['测试产品部', '产品经理', '企业微信通讯录姓名', '姓名同步于'].every((s) => identityInfo.text.includes(s)));
+    check(`employee ${width}px: no page overflow or old identity label`, identityInfo.width <= identityInfo.viewport + 1 && !identityInfo.text.includes('旧占位身份'));
+    await shot('employee-profile-' + width);
+  }
   const measureTitles = async () => evaluate(`(()=>{
     const root=document.querySelector('kf-app').shadowRoot;
     const headings=[root.querySelector('kf-topbar').shadowRoot.querySelector('h1'),...root.querySelector('main').firstElementChild.shadowRoot.querySelectorAll('[data-type-card] h3,.head h2')];

+ 32 - 0
server/identity/build-release.mjs

@@ -0,0 +1,32 @@
+import fs from 'node:fs';
+import path from 'node:path';
+import crypto from 'node:crypto';
+import { fileURLToPath } from 'node:url';
+
+export function buildIdentityFunctions(base) {
+  const directory = path.dirname(fileURLToPath(import.meta.url));
+  const source = ['wecom-directory.mjs', 'employee-handlers.mjs'].map((name) => fs.readFileSync(path.join(directory, name), 'utf8').replace(/^import .*;\r?\n/gm, '').replace(/^export /gm, '')).join('\n');
+  const hook = 'attachWorkshopHandlers(app,{store,data:DATA});';
+  if (base.name !== 'ksfGetPublicBoard' || typeof base.code !== 'string' || base.code.split(hook).length !== 2 || base.code.includes('attachWecomEmployeeHandlers')) throw Error('UNEXPECTED_PUBLIC_FUNCTION');
+  const extended = source + '\n' + base.code.replace(hook, hook + '\n    attachWecomEmployeeHandlers(app,{store,settings});');
+  const call = 'runDatabaseFunction("ksfGetPublicBoard",request,response,';
+  if (extended.split(call).length !== 2) throw Error('UNEXPECTED_FUNCTION_ENTRY');
+  const result = [
+    { name: 'ksfGetPublicBoard', objectId: base.objectId, path: base.path, priorSha256: crypto.createHash('sha256').update(base.code).digest('hex'), code: extended },
+    { name: 'ksfSyncWecomEmployee', path: '/ksfSyncWecomEmployee', priorSha256: null, code: extended.replace(call, 'runDatabaseFunction("ksfSyncWecomEmployee",request,response,') },
+  ];
+  for (const entry of result) { new Function(entry.code); entry.sha256 = crypto.createHash('sha256').update(entry.code).digest('hex'); }
+  return result;
+}
+
+if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
+  const args = process.argv.slice(2);
+  if (args.length !== 4 || args[0] !== '--base' || args[2] !== '--out') throw Error('Use --base PRIVATE_FUNCTION_SNAPSHOT --out NEW_ABSOLUTE_DIRECTORY');
+  const out = args[3];
+  if (!path.isAbsolute(out) || fs.existsSync(out)) throw Error('NEW_ABSOLUTE_DIRECTORY_REQUIRED');
+  const entries = buildIdentityFunctions(JSON.parse(fs.readFileSync(args[1], 'utf8')));
+  fs.mkdirSync(out, { recursive: true });
+  for (const entry of entries) fs.writeFileSync(path.join(out, entry.name + '.js'), entry.code);
+  fs.writeFileSync(path.join(out, 'manifest.json'), JSON.stringify({ version: 'ksf.wecom-identity.20261103.v1', functions: entries.map(({ code, ...rest }) => rest) }, null, 2));
+  console.log(JSON.stringify({ ok: true, functions: entries.length, out, productionWrites: 0 }));
+}

+ 15 - 0
server/identity/directory.example.json

@@ -0,0 +1,15 @@
+{
+  "enabled": true,
+  "corpId": null,
+  "secret": null,
+  "organizationName": null,
+  "bots": {
+    "REPLACE_WITH_VERIFIED_BOT_ID": {
+      "mappingVerified": true,
+      "senderIdType": "mapped",
+      "userIds": {
+        "REPLACE_WITH_ADAPTER_SENDER_ID": null
+      }
+    }
+  }
+}

+ 77 - 0
server/identity/employee-handlers.mjs

@@ -0,0 +1,77 @@
+import { createWecomDirectory } from './wecom-directory.mjs';
+
+/** Overlay current directory attribution; historical records and report text remain intact. */
+export function attachWecomEmployeeHandlers(app, { store, settings, fetchImpl = fetch, now = () => Date.now() }) {
+  const { fail, need, hash, canonical } = app;
+  const directory = createWecomDirectory({ config: settings.wecomDirectory, hash, canonical, fetchImpl, now });
+  const originalBoard = app.handlers.ksfGetPublicBoard;
+  const plain = (v) => v && typeof v === 'object' && !Array.isArray(v);
+  const cleanText = (v) => typeof v === 'string' && v.trim() && v.length <= 200 ? v.trim() : null;
+  const validProfile = (p) => plain(p) && p.version === 'ksf.wecom-employee.v1' && /^employee:[a-f0-9]{64}$/.test(p.key) && p.source === 'wecom_directory' && p.nameVerified === true && cleanText(p.name) && Number.isFinite(Date.parse(p.verifiedAt));
+  function publicProfile(session) {
+    const account = session?.meta?.attribution?.participantAccount;
+    const key = directory.accountKey(account);
+    if (!key) return null;
+    const profile = settings.wecomEmployees?.[key];
+    if (validProfile(profile) && profile.key === directory.expectedEmployeeKey(account)) return {
+      key: profile.key, name: cleanText(profile.name), displayName: cleanText(profile.displayName),
+      departmentNames: [...new Set((Array.isArray(profile.departmentNames) ? profile.departmentNames : []).map(cleanText).filter(Boolean))],
+      position: cleanText(profile.position), organizationName: cleanText(profile.organizationName),
+      source: 'wecom_directory', nameVerified: true, verifiedAt: profile.verifiedAt,
+    };
+    return {
+      key: 'channel-employee:' + key, name: null, displayName: cleanText(account.accountName),
+      departmentNames: [], position: null, organizationName: null,
+      source: 'wecom_sender', nameVerified: false, verifiedAt: null,
+    };
+  }
+  app.handlers.ksfGetPublicBoard = async (p, actor) => {
+    const result = await originalBoard(p, actor);
+    const sessions = new Map();
+    const profileFor = async (id) => {
+      if (!id) return null;
+      if (!sessions.has(id)) sessions.set(id, store.get('KfSession', id).then(publicProfile));
+      return sessions.get(id);
+    };
+    for (const kind of ['session', 'artifact', 'project', 'opportunity']) {
+      for (const row of result[kind] || []) {
+        const id = kind === 'session' ? row.objectId : row.sessionRef?.objectId || row.ownerSession || row.sourceSession;
+        const employee = await profileFor(id);
+        if (!employee) continue;
+        row.employee = employee;
+        const name = employee.name || employee.displayName || '姓名待同步';
+        if (kind === 'session') row.studentName = name;
+        else if (kind === 'opportunity') row.uploadedBy = name;
+        else row.ownerName = name;
+      }
+    }
+    return result;
+  };
+  app.handlers.ksfSyncWecomEmployee = async (p, actor) => {
+    need(actor, 'identity:write');
+    if (!plain(p) || Object.keys(p).some((k) => !['requestId', 'sessionIds', 'apply'].includes(k)) ||
+        !Array.isArray(p.sessionIds) || !p.sessionIds.length || p.sessionIds.length > 100 ||
+        p.sessionIds.some((id) => typeof id !== 'string' || !/^[\w-]{1,40}$/.test(id)) ||
+        p.apply !== undefined && typeof p.apply !== 'boolean') fail('VALIDATION_FAILED', '需提供1至100个会话编号;姓名只从企业微信获取');
+    if (p.apply === true && (typeof p.requestId !== 'string' || !p.requestId.trim())) fail('VALIDATION_FAILED', '写入须提供业务请求键');
+    const ids = [...new Set(p.sessionIds)], planned = new Map();
+    for (const id of ids) {
+      const session = await store.get('KfSession', id);
+      if (!session) fail('REFERENCE_NOT_FOUND', '会话不存在');
+      if (!actor.scopes.includes('*') && session.meta?.ownerId !== actor.id) fail('FORBIDDEN', '会话不属于当前服务身份');
+      const account = session.meta?.attribution?.participantAccount;
+      const profile = await directory.resolve(account);
+      planned.set(directory.accountKey(account), profile);
+    }
+    const result = { profiles: [...new Map([...planned.values()].map((v) => [v.key, v])).values()], sessionsChecked: ids.length, accountsChecked: planned.size, applied: false };
+    if (p.apply !== true) return result;
+    return app.operation(actor, 'wecom-employee-sync', p, async (s) => {
+      await s.lock('ksf:wecom-employee-directory');
+      const [runtime] = await s.list('KsfRuntimeConfig', { where: { key: 'runtime' }, limit: 1, forUpdate: true });
+      if (!runtime?.value) fail('CAPABILITY_UNAVAILABLE', '运行配置不存在');
+      if (canonical(runtime.value.wecomDirectory || null) !== canonical(settings.wecomDirectory || null)) fail('VERSION_CONFLICT', '通讯录配置已变化,请重新核对');
+      await s.update('KsfRuntimeConfig', runtime.objectId, { value: { ...runtime.value, wecomEmployees: { ...runtime.value.wecomEmployees, ...Object.fromEntries(planned) } } });
+      return { ...result, applied: true };
+    });
+  };
+}

+ 140 - 0
server/identity/manage.mjs

@@ -0,0 +1,140 @@
+/** Maintenance CLI. Connection and directory secrets stay in private files. */
+import fs from 'node:fs';
+import path from 'node:path';
+import crypto from 'node:crypto';
+import { fileURLToPath } from 'node:url';
+
+const sha = (value) => crypto.createHash('sha256').update(value).digest('hex');
+const fail = (code) => { throw Object.assign(new Error(code), { code }); };
+const root = path.dirname(fileURLToPath(import.meta.url));
+function privateJson(file) {
+  if (!path.isAbsolute(file)) fail('ABSOLUTE_PRIVATE_CONFIG_REQUIRED');
+  for (let current = file; current !== path.dirname(current); current = path.dirname(current)) if (fs.lstatSync(current).isSymbolicLink()) fail('UNSAFE_PRIVATE_CONFIG');
+  const stat = fs.statSync(file);
+  if (!stat.isFile() || stat.size > 1024 * 1024 || process.platform !== 'win32' && (stat.mode & 0o077)) fail('UNSAFE_PRIVATE_CONFIG');
+  return JSON.parse(fs.readFileSync(file, 'utf8'));
+}
+export async function manage({ args, request, invoke, releaseDir = path.join(root, 'dist') }) {
+  const command = args[0], apply = args.includes('--apply');
+  const flags = { deploy: ['--apply', '--receipt-dir'], configure: ['--apply', '--file', '--receipt-dir'], sync: ['--apply', '--sessions', '--request-id'], rollback: ['--apply', '--receipt'] }[command];
+  if (!flags) fail('USAGE');
+  const seen = new Set();
+  for (let i = 1; i < args.length; i++) { const flag = args[i]; if (!flags.includes(flag) || seen.has(flag)) fail('USAGE'); seen.add(flag); if (flag !== '--apply') { i++; if (!args[i] || args[i].startsWith('--')) fail('USAGE'); } }
+  const value = (flag) => { const index = args.indexOf(flag); if (index < 0) return null; if (!args[index + 1] || args[index + 1].startsWith('--')) fail('USAGE'); return args[index + 1]; };
+  const scan = async (name) => (await request('GET', '/classes/Function?' + new URLSearchParams({ where: JSON.stringify({ name }), limit: '2' }))).results;
+  if (command === 'sync') {
+    let ids = value('--sessions')?.split(',').filter(Boolean); const requestId = value('--request-id');
+    if (ids?.length === 1 && ids[0] === 'all') {
+      ids = []; let cursor = null;
+      for (let page = 0; page < 100; page++) {
+        const read = await invoke('ksfGetPublicBoard', { limit: 500, ...(cursor ? { cursor } : {}) });
+        if (read?.result?.ok !== true) fail('PUBLIC_SESSIONS_UNAVAILABLE');
+        ids.push(...read.result.data.session.filter((s) => s.employee).map((s) => s.objectId));
+        cursor = read.result.data.nextCursor; if (!cursor) break;
+      }
+      if (cursor) fail('PUBLIC_SESSIONS_TRUNCATED');
+    }
+    if (!ids?.length || apply && !requestId) fail('USAGE');
+    const response = await invoke('ksfSyncWecomEmployee', { sessionIds: ids, apply, ...(requestId ? { requestId } : {}) });
+    if (!response?.result?.ok) fail(response?.result?.errors?.[0]?.code || 'EMPLOYEE_SYNC_FAILED');
+    return response.result.data;
+  }
+  if (command === 'configure') {
+    const cfg = privateJson(value('--file') || '');
+    if (!cfg || typeof cfg !== 'object' || Array.isArray(cfg) || Object.keys(cfg).some((k) => !['enabled', 'corpId', 'secret', 'organizationName', 'bots'].includes(k)) || cfg.enabled !== true || typeof cfg.corpId !== 'string' || !cfg.corpId.trim() || typeof cfg.secret !== 'string' || !cfg.secret.trim() || !cfg.bots || typeof cfg.bots !== 'object' || Array.isArray(cfg.bots)) fail('INVALID_DIRECTORY_CONFIG');
+    for (const bot of Object.values(cfg.bots)) {
+      if (bot.mappingVerified !== true || !['mapped', 'directory_userid'].includes(bot.senderIdType) || bot.senderIdType === 'mapped' && (!bot.userIds || typeof bot.userIds !== 'object')) fail('INVALID_DIRECTORY_MAPPING');
+    }
+    const [runtime] = (await request('GET', '/classes/KsfRuntimeConfig?' + new URLSearchParams({ where: JSON.stringify({ key: 'runtime' }), limit: '2' }))).results || [];
+    if (!runtime?.value) fail('RUNTIME_NOT_FOUND');
+    let backup = null;
+    if (apply) {
+      const receiptDir = value('--receipt-dir'); if (!receiptDir || !path.isAbsolute(receiptDir)) fail('RECEIPT_DIRECTORY_REQUIRED');
+      fs.mkdirSync(receiptDir, { recursive: true, mode: 0o700 });
+      backup = path.join(receiptDir, 'directory-config-before-' + crypto.randomUUID() + '.json');
+      fs.writeFileSync(backup, JSON.stringify(runtime, null, 2), { flag: 'wx', mode: 0o600 });
+      const current = await request('GET', '/classes/KsfRuntimeConfig/' + runtime.objectId);
+      if (sha(JSON.stringify(current.value)) !== sha(JSON.stringify(runtime.value))) fail('CONCURRENT_CONFIG_CHANGE');
+      await request('PUT', '/classes/KsfRuntimeConfig/' + runtime.objectId, { value: { ...runtime.value, wecomDirectory: cfg } });
+      const checked = await request('GET', '/classes/KsfRuntimeConfig/' + runtime.objectId);
+      if (JSON.stringify(checked.value.wecomDirectory) !== JSON.stringify(cfg)) fail('CONFIG_READBACK_MISMATCH');
+    }
+    return { ok: true, applied: apply, configuredBots: Object.keys(cfg.bots).length, directorySecretPrinted: false, backup };
+  }
+  if (command === 'rollback') {
+    const receiptFile = value('--receipt'); if (!receiptFile) fail('USAGE');
+    const receipt = privateJson(receiptFile);
+    if (receipt.version !== 'ksf.wecom-identity.20261103.v1' || !Array.isArray(receipt.entries) || receipt.entries.length !== 2) fail('INVALID_RECEIPT');
+    const restore = [];
+    if (new Set(receipt.entries.map((e) => e.name)).size !== 2) fail('INVALID_RECEIPT');
+    for (const entry of receipt.entries) {
+      if (!['ksfGetPublicBoard', 'ksfSyncWecomEmployee'].includes(entry.name)) fail('INVALID_RECEIPT');
+      if (!entry.id) { if ((await scan(entry.name)).length) fail('UNKNOWN_CREATED_FUNCTION'); continue; }
+      if (!/^[\w-]+$/.test(entry.id)) fail('INVALID_RECEIPT');
+      const row = await request('GET', '/classes/Function/' + entry.id);
+      if (entry.prior && sha(entry.prior.code) !== entry.priorSha256) fail('BACKUP_MISMATCH');
+      if (row.name !== entry.name) fail('CONCURRENT_FUNCTION_CHANGE');
+      if (entry.prior && sha(row.code) === entry.priorSha256) continue;
+      if (sha(row.code) !== entry.afterSha256) fail('CONCURRENT_FUNCTION_CHANGE');
+      restore.push(entry);
+    }
+    if (apply) for (const entry of restore.reverse()) {
+      const row = await request('GET', '/classes/Function/' + entry.id); if (sha(row.code) !== entry.afterSha256) fail('CONCURRENT_FUNCTION_CHANGE');
+      if (entry.prior) { await request('PUT', '/classes/Function/' + entry.id, { code: entry.prior.code }); if (sha((await request('GET', '/classes/Function/' + entry.id)).code) !== entry.priorSha256) fail('ROLLBACK_READBACK_MISMATCH'); }
+      else await request('DELETE', '/classes/Function/' + entry.id);
+    }
+    return { ok: true, operation: 'rollback', applied: apply };
+  }
+  if (command !== 'deploy') fail('USAGE');
+  const manifest = JSON.parse(fs.readFileSync(path.join(releaseDir, 'manifest.json'), 'utf8'));
+  if (manifest.version !== 'ksf.wecom-identity.20261103.v1') fail('BAD_RELEASE');
+  const planned = [];
+  for (const entry of manifest.functions) {
+    const code = fs.readFileSync(path.join(releaseDir, entry.name + '.js'), 'utf8');
+    if (sha(code) !== entry.sha256) fail('RELEASE_HASH_MISMATCH');
+    const matches = await scan(entry.name); if (matches.length > 1) fail('DUPLICATE_FUNCTION');
+    const prior = matches[0];
+    if (prior && sha(prior.code) === entry.sha256) { planned.push({ ...entry, prior, code, unchanged: true }); continue; }
+    if (entry.priorSha256 ? !prior || prior.objectId !== entry.objectId || sha(prior.code) !== entry.priorSha256 : prior) fail('FUNCTION_BASELINE_MISMATCH');
+    planned.push({ ...entry, prior, code });
+  }
+  if (!apply) return { ok: true, readOnly: true, functions: planned.map((p) => ({ name: p.name, unchanged: !!p.unchanged, sha256: p.sha256 })) };
+  if (planned.every((p) => p.unchanged)) return { ok: true, unchanged: true };
+  // Refuse a mixed partial install; preserve its receipt for an explicit recovery.
+  if (planned.some((p) => p.unchanged)) fail('PARTIAL_INSTALL_REQUIRES_REVIEW');
+  const receiptDir = value('--receipt-dir'); if (!receiptDir || !path.isAbsolute(receiptDir)) fail('RECEIPT_DIRECTORY_REQUIRED');
+  fs.mkdirSync(receiptDir, { recursive: true, mode: 0o700 });
+  const receiptFile = path.join(receiptDir, 'identity-deploy-' + crypto.randomUUID() + '.json');
+  const receipt = { version: manifest.version, status: 'prepared', entries: planned.map((p) => ({ name: p.name, id: p.prior?.objectId || null, prior: p.prior || null, priorSha256: p.priorSha256, afterSha256: p.sha256 })) };
+  const save = () => fs.writeFileSync(receiptFile, JSON.stringify(receipt, null, 2), { mode: 0o600 }); save();
+  // Install sync before public projection so a partial failure leaves existing public reads intact.
+  for (const entry of [...planned].reverse()) {
+    const current = await scan(entry.name);
+    if (entry.prior ? current.length !== 1 || sha(current[0].code) !== entry.priorSha256 : current.length !== 0) fail('CONCURRENT_FUNCTION_CHANGE');
+    let id = entry.prior?.objectId;
+    if (id) await request('PUT', '/classes/Function/' + id, { code: entry.code });
+    else id = (await request('POST', '/classes/Function', { name: entry.name, path: entry.path, code: entry.code, ACL: planned[0].prior.ACL })).objectId;
+    receipt.entries.find((r) => r.name === entry.name).id = id; save();
+    const read = await request('GET', '/classes/Function/' + id); if (sha(read.code) !== entry.sha256) fail('FUNCTION_READBACK_MISMATCH');
+  }
+  receipt.status = 'verified'; save();
+  return { ok: true, applied: true, receipt: receiptFile, functions: planned.length, businessRecordsChanged: 0 };
+}
+
+if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
+  try {
+    const cfg = privateJson(process.env.KSF_PLATFORM_CONFIG || '');
+    if (cfg.appId !== 'FKieRbIoGt_main' || !cfg.masterKey) fail('INVALID_PLATFORM_CONFIG');
+    for (const key of ['parseUrl', 'functionsUrl']) if (new URL(cfg[key]).origin !== 'https://kangshifu.fmode.cn') fail('WRONG_TARGET');
+    const headers = { 'X-Parse-Application-Id': cfg.appId, 'X-Parse-Master-Key': cfg.masterKey, 'Content-Type': 'application/json' };
+    const call = async (url, method, body) => {
+      let response;
+      try { response = await fetch(url, { method, headers, body: body === undefined ? undefined : JSON.stringify(body), redirect: 'error', signal: AbortSignal.timeout(30000) }); } catch { fail('PLATFORM_UNAVAILABLE'); }
+      if (!response.ok) fail('PLATFORM_HTTP_ERROR');
+      try { return await response.json(); } catch { fail('PLATFORM_INVALID_RESPONSE'); }
+    };
+    const request = (method, route, body) => call(cfg.parseUrl.replace(/\/$/, '') + route, method, body);
+    const invoke = (name, params) => call(cfg.functionsUrl, 'POST', { path: '/' + name, params });
+    console.log(JSON.stringify(await manage({ args: process.argv.slice(2), request, invoke }), null, 2));
+  } catch (e) { console.error(JSON.stringify({ ok: false, code: /^[A-Z][A-Z0-9_]+$/.test(e.code || '') ? e.code : 'IDENTITY_MAINTENANCE_STOPPED', message: '已停止;保留私有回执核对,不输出凭据或接口原文。' })); process.exitCode = 1; }
+}

+ 6 - 0
server/identity/platform.example.json

@@ -0,0 +1,6 @@
+{
+  "appId": "FKieRbIoGt_main",
+  "masterKey": null,
+  "parseUrl": "https://kangshifu.fmode.cn/parse",
+  "functionsUrl": "https://kangshifu.fmode.cn/api/functions"
+}

+ 8 - 0
server/identity/scheduled-sync.mjs

@@ -0,0 +1,8 @@
+/** One maintenance run for cron/systemd; no credentials are accepted on the command line. */
+import { spawnSync } from 'node:child_process';
+import { fileURLToPath } from 'node:url';
+if (process.argv.length !== 2) { console.error('NO_ARGUMENTS_ALLOWED'); process.exit(1); }
+const key = 'wecom-identity:scheduled:' + new Date().toISOString().slice(0, 16);
+const result = spawnSync(process.execPath, [fileURLToPath(new URL('./manage.mjs', import.meta.url)), 'sync', '--sessions', 'all', '--apply', '--request-id', key], { encoding: 'utf8', windowsHide: true });
+if (result.error) { console.error('IDENTITY_SYNC_PROCESS_FAILED'); process.exit(1); }
+process.stdout.write(result.stdout || ''); process.stderr.write(result.stderr || ''); process.exit(result.status ?? 1);

+ 69 - 0
server/identity/wecom-directory.mjs

@@ -0,0 +1,69 @@
+/** Server only. An aibot sender is a directory UserID only after an explicit mapping. */
+export function createWecomDirectory({ config, hash, canonical, fetchImpl = fetch, now = () => Date.now() }) {
+  const fail = (code) => { throw Object.assign(new Error(code), { code }); };
+  const text = (v) => typeof v === 'string' && v.trim() && v.length <= 200 ? v.trim() : null;
+  const list = (v) => Array.isArray(v) ? v : [];
+  let token = null, tokenUntil = 0;
+  const requests = new Map();
+  async function json(url) {
+    let response;
+    try { response = await fetchImpl(url, { redirect: 'error', signal: AbortSignal.timeout(10000) }); }
+    catch { fail('WECOM_DIRECTORY_UNAVAILABLE'); }
+    if (!response.ok) fail('WECOM_DIRECTORY_UNAVAILABLE');
+    let body;
+    try { body = await response.json(); } catch { fail('WECOM_DIRECTORY_INVALID_RESPONSE'); }
+    if (body?.errcode !== 0) fail(body?.errcode === 60011 || body?.errcode === 48002 ? 'WECOM_DIRECTORY_PERMISSION_REQUIRED' : 'WECOM_DIRECTORY_API_ERROR');
+    return body;
+  }
+  async function accessToken() {
+    if (token && tokenUntil > now()) return token;
+    const body = await json('https://qyapi.weixin.qq.com/cgi-bin/gettoken?' + new URLSearchParams({ corpid: config.corpId, corpsecret: config.secret }));
+    if (typeof body.access_token !== 'string' || !body.access_token || body.access_token.length > 8192 || !Number.isFinite(body.expires_in) || body.expires_in < 60) fail('WECOM_DIRECTORY_INVALID_RESPONSE');
+    token = body.access_token; tokenUntil = now() + (body.expires_in - 30) * 1000;
+    return token;
+  }
+  const accountKey = (account) => {
+    if (account?.platform !== 'wecom' || account.identifierType !== 'wecom_aibot_sender' || !text(account.scopeId) || !text(account.accountId)) return null;
+    return hash(canonical(['wecom_aibot_sender', account.scopeId, account.accountId]));
+  };
+  function mapping(account) {
+    if (!accountKey(account)) fail('WECOM_SENDER_IDENTITY_REQUIRED');
+    if (config?.enabled !== true || !text(config.corpId) || !text(config.secret)) fail('WECOM_DIRECTORY_NOT_CONFIGURED');
+    const bot = config.bots?.[account.scopeId];
+    if (!bot || bot.mappingVerified !== true) fail('WECOM_SENDER_MAPPING_REQUIRED');
+    // Never guess an ID type from its prefix or try a bot-scoped identifier against the directory.
+    const userId = bot.senderIdType === 'directory_userid' ? account.accountId : bot.senderIdType === 'mapped' ? bot.userIds?.[account.accountId] : null;
+    if (!text(userId)) fail('WECOM_SENDER_MAPPING_REQUIRED');
+    return userId;
+  }
+  async function resolve(account) {
+    const userId = mapping(account), cacheKey = accountKey(account);
+    if (requests.has(cacheKey)) return requests.get(cacheKey);
+    const pending = (async () => {
+      const access = await accessToken();
+      const user = await json('https://qyapi.weixin.qq.com/cgi-bin/user/get?' + new URLSearchParams({ access_token: access, userid: userId }));
+      if (user.userid !== userId || !text(user.name) || user.status !== 1) fail('WECOM_DIRECTORY_MEMBER_UNAVAILABLE');
+      let departmentNames = [];
+      if (list(user.department).length) {
+        try {
+          const departments = await json('https://qyapi.weixin.qq.com/cgi-bin/department/list?' + new URLSearchParams({ access_token: access }));
+          const byId = new Map(list(departments.department).map((d) => [d.id, text(d.name)]));
+          departmentNames = list(user.department).map((id) => byId.get(id)).filter(Boolean);
+        } catch { /* Optional department access must not hide a verified member name. */ }
+      }
+      return {
+        version: 'ksf.wecom-employee.v1', key: 'employee:' + hash(canonical([config.corpId, userId])),
+        name: text(user.name), displayName: text(user.alias) || text(user.name),
+        departmentNames: [...new Set(departmentNames)], position: text(user.position),
+        organizationName: text(config.organizationName), source: 'wecom_directory', nameVerified: true,
+        verifiedAt: new Date(now()).toISOString(),
+      };
+    })();
+    requests.set(cacheKey, pending);
+    try { return await pending; } catch (e) { requests.delete(cacheKey); throw e; }
+  }
+  const expectedEmployeeKey = (account) => {
+    try { return 'employee:' + hash(canonical([config.corpId, mapping(account)])); } catch { return null; }
+  };
+  return { accountKey, resolve, expectedEmployeeKey };
+}

+ 10 - 19
src/core/kfs/build.js

@@ -7,31 +7,17 @@
 import { stageOf, evidenceOf, normalizeLayer, uploadMethodLabel, STAGES } from '../taxonomy/taxonomy.js';
 import { toIso, toTime, latest } from './util.js';
 import { normalizeOpportunity } from '../opportunity/opportunity.js';
+import { resolveEmployeeRows } from './employee.js';
 
 export function buildModel(raw, { now = Date.now(), stuck = { minMsgs: 5, stuckHours: 8 } } = {}) {
-  const rows = {
+  const rows = resolveEmployeeRows({
     project: Array.isArray(raw.project) ? raw.project : [],
     artifact: Array.isArray(raw.artifact) ? raw.artifact : [],
     insight: Array.isArray(raw.insight) ? raw.insight : [],
     source: Array.isArray(raw.source) ? raw.source : [],
     session: Array.isArray(raw.session) ? raw.session : [],
     opportunity: Array.isArray(raw.opportunity) ? raw.opportunity : [],
-  };
-  // New sessions have stable participant keys. Only disambiguate display names when needed.
-  const identities = new Map();
-  for (const s of rows.session) {
-    const key=s.meta?.participantKey;
-    if(key){if(!identities.has(s.studentName))identities.set(s.studentName,new Map());identities.get(s.studentName).set(key,s.groupId||key.slice(-6));}
-  }
-  const displayName=(name,key)=>{
-    const people=identities.get(name);if(!people||people.size<2)return name;
-    const group=people.get(key);
-    const repeated=group&&[...people.values()].filter(g=>g===group).length>1;
-    return `${name}(${group||key?.slice(-8)||'未绑定'}${repeated?' · '+key.slice(-8):''})`;
-  };
-  rows.session=rows.session.map(s=>({...s,studentName:displayName(s.studentName,s.meta?.participantKey)}));
-  for(const k of ['artifact','project'])rows[k]=rows[k].map(r=>({...r,ownerName:displayName(r.ownerName,r.uploadedByKey)}));
-  rows.opportunity=rows.opportunity.map(r=>({...r,uploadedBy:displayName(r.uploadedBy,r.uploadedByKey)}));
+  });
 
   /* ---------- 成果 ---------- */
   const citedIds = new Set();
@@ -62,6 +48,7 @@ export function buildModel(raw, { now = Date.now(), stuck = { minMsgs: 5, stuckH
       id: r.objectId,
       title: String(r.title || '(未命名项目)'),
       ownerName: String(r.ownerName || ''),
+      employee: r.employee || null,
       progressState: String(r.progressState || ''),
       currentConceptId: String(r.currentConceptRef?.objectId || ''),
       pendingQuestions: r.meta?.pendingQuestions || [],
@@ -132,7 +119,7 @@ export function buildModel(raw, { now = Date.now(), stuck = { minMsgs: 5, stuckH
   const opportunities = rows.opportunity.map((r) => normalizeOpportunity(r, { artifactById, projectById, toTime }));
   const opportunityById = Object.fromEntries(opportunities.map((o) => [o.id, o]));
 
-  /* ---------- 成员:姓名是身份,会话是进度,成果是「交没交」的权威 ---------- */
+  /* ---------- 成员:账号先归一、同名再区分;会话是进度,成果是交付依据 ---------- */
   const members = new Map();
   const touch = (name, key = '') => {
     const n = String(name || '').trim();
@@ -150,6 +137,7 @@ export function buildModel(raw, { now = Date.now(), stuck = { minMsgs: 5, stuckH
     const m = touch(s.studentName, s.meta?.participantKey || '');
     if (!m) continue;
     m.sessionIds.push(s.objectId);
+    if (s.employee) m.employee = s.employee;
     m.groupId = m.groupId || String(s.groupId || '');
     m.msgCount = Math.max(m.msgCount, Number(s.msgCount) || 0);
     const st = stageOf(s.stageCode || s.stage);
@@ -163,6 +151,7 @@ export function buildModel(raw, { now = Date.now(), stuck = { minMsgs: 5, stuckH
     const m = touch(a.ownerName, a.ownerKey);
     if (!m) continue;
     m.artifactIds.push(a.id);
+    if (a.employee) m.employee = a.employee;
     if (a.stageObj) m.stagesSubmitted.add(a.stageObj.code);
     if (a.stageObj && (!m.stageObj || a.stageObj.no > m.stageObj.no)) m.stageObj = a.stageObj;
     m.lastActiveAt = latest([m.lastActiveAt, a.createdAt]);
@@ -171,13 +160,14 @@ export function buildModel(raw, { now = Date.now(), stuck = { minMsgs: 5, stuckH
     const m = touch(p.ownerName, p.ownerKey);
     if (!m) continue;
     m.projectIds.push(p.id);
+    if (p.employee) m.employee = p.employee;
     if(p.progressState==='blocked')m.blocked=true;
     if(p.stage&&(!m.stageObj||p.stage.no>m.stageObj.no))m.stageObj=p.stage;
     m.lastActiveAt = latest([m.lastActiveAt, p.lastActiveAt]);
   }
   for (const o of opportunities) {
     const m = touch(o.upload.uploadedBy, o.upload.uploadedByKey);
-    if (m) m.oppIds.push(o.id);
+    if (m) { m.oppIds.push(o.id); if (o.upload.employee) m.employee = o.upload.employee; }
   }
   const stuckMs = (Number(stuck.stuckHours) || 8) * 3600000;
   const memberList = [...members.values()].map((m) => {
@@ -211,6 +201,7 @@ function normArtifact(r, citedIds, toTimeFn) {
     shortTitle: String(r.title || '(未命名成果)').replace(/\s*-\s*[a-z_]+$/, ''),
     type: String(r.artifactType || ({market_trend_report:'category_report',competitor_analysis_report:'competitor_report',voc_report:'voc_report',pain_point_report:'pain_list',product_concept_report:'concept'}[r.reportType]) || ''),
     ownerName: String(r.ownerName || ''),
+    employee: r.employee || null,
     ownerKey,
     stageObj: st,
     stage: st ? st.label : r.stage || '—',

+ 51 - 0
src/core/kfs/employee.js

@@ -0,0 +1,51 @@
+/** Public employee profiles carry a stable, opaque key; names never establish identity. */
+export function resolveEmployeeRows(input) {
+  const rows = Object.fromEntries(Object.entries(input).map(([kind, list]) => [kind, [...list]]));
+  const sessions = new Map(rows.session.map((s) => [s.objectId, s]));
+  const participants = new Map(rows.session.filter((s) => s.meta?.participantKey).map((s) => [s.meta.participantKey, s]));
+  const profile = (r) => {
+    const p = r?.employee;
+    if (!p || !/^(?:employee|channel-employee):[a-f0-9]{64}$/.test(p.key)) return null;
+    return p;
+  };
+  const sessionOf = (r) => sessions.get(r.sessionRef?.objectId || r.ownerSession || r.sourceSession) || participants.get(r.uploadedByKey);
+  const nameOf = (r, kind) => kind === 'session' ? r.studentName : kind === 'opportunity' ? r.uploadedBy : r.ownerName;
+  const identity = (r, kind) => {
+    const s = kind === 'session' ? r : sessionOf(r), employee = profile(r) || profile(s);
+    const key = employee?.key || s?.meta?.participantKey || r.uploadedByKey || '';
+    const name = employee?.name || employee?.displayName || nameOf(r, kind) || '';
+    return { key, name, employee, group: r.groupId || s?.groupId || '', session: s };
+  };
+  const names = new Map();
+  for (const kind of ['session', 'artifact', 'project', 'opportunity']) {
+    for (const r of rows[kind]) {
+      const p = identity(r, kind);
+      if (!p.name || !p.key) continue;
+      if (!names.has(p.name)) names.set(p.name, new Map());
+      const prior = names.get(p.name).get(p.key);
+      names.get(p.name).set(p.key, { ...p, group: prior?.group || p.group });
+    }
+  }
+  for (const kind of ['session', 'artifact', 'project', 'opportunity']) {
+    rows[kind] = rows[kind].map((r) => {
+      const p = identity(r, kind), sameName = names.get(p.name);
+      let name = p.name;
+      if (sameName?.size > 1) {
+        const person = sameName.get(p.key), label = person?.employee?.departmentNames?.join('、') || person?.group || p.key.slice(-6) || '未绑定';
+        const repeated = [...sameName.values()].filter((x) => (x.employee?.departmentNames?.join('、') || x.group || x.key.slice(-6)) === label).length > 1;
+        name += `(${label}${repeated ? ' · ' + p.key.slice(-8) : ''})`;
+      }
+      const field = kind === 'session' ? 'studentName' : kind === 'opportunity' ? 'uploadedBy' : 'ownerName';
+      return { ...r, [field]: name, ...(p.employee ? { employee: p.employee } : {}) };
+    });
+  }
+  return rows;
+}
+
+export function employeeCaption(employee) {
+  if (!employee) return '';
+  const parts = [employee.organizationName, ...(employee.departmentNames || []), employee.position].filter(Boolean);
+  if (employee.nameVerified && employee.displayName && employee.displayName !== employee.name) parts.push('显示名:' + employee.displayName);
+  parts.push(employee.nameVerified ? '企业微信通讯录姓名' : employee.displayName ? '企业微信显示名 · 实名待同步' : '企业微信发送者 · 姓名待同步');
+  return [...new Set(parts)].join(' · ');
+}

+ 1 - 0
src/core/opportunity/opportunity.js

@@ -17,6 +17,7 @@ import { scoreStats } from './score.js';
 /** 「谁传的」四要素 + 一个扩展位(后续会开多个数字分身,别写死单个) */
 export function normUpload(row, { by, key } = {}) {
   const u = {
+    employee: row.employee || null,
     uploadedBy: String(row.uploadedBy && row.uploadedBy !== 'skill' && row.uploadedBy !== 'assistant' ? row.uploadedBy : by || ''),
     uploadedByKey: String(row.uploadedByKey || key || ''),
     agentName: String(row.agentName || ''),

+ 3 - 1
src/pages/view-members.js

@@ -9,6 +9,7 @@ import { fmtAgo, fmtDate, fmtAgoText } from '../core/index.js';
 import { href } from '../utils/router.js';
 import { t, head, VIEW_CSS } from './shared.js';
 import { icon } from '../components/icons.js';
+import { employeeCaption } from '../core/kfs/employee.js';
 
 export class ViewMembers extends LitElement {
   static properties = {
@@ -46,6 +47,7 @@ export class ViewMembers extends LitElement {
     .nm { font-weight: 600; color: var(--kf-ink); }
     .nm a { color: inherit; text-decoration: none; }
     .nm a:hover { color: var(--kf-accent); }
+    .identity-note { display: block; font-size: 12px; line-height: 1.65; font-weight: 400; color: var(--kf-ink-3); overflow-wrap: anywhere; }
     .me-tag { display: inline-block; font-size: 12px; line-height: 16px; color: var(--kf-accent-ink); border: 1px solid rgba(91, 91, 214, 0.30); border-radius: var(--r-sm); padding: 0 4px; margin-left: 6px; }
     .stuck { display: inline-flex; align-items: center; gap: 4px; color: var(--kf-s); font-weight: 600; }
     .ok { color: var(--kf-ok-text); }
@@ -98,7 +100,7 @@ export class ViewMembers extends LitElement {
             <li class="head"><span></span><span>成员</span><span>当前阶段</span><span>会话</span><span>成果</span><span>机会卡</span><span>最后活动</span><span>状态</span></li>
             ${list.map((m) => html`<li data-member=${m.name}>
               <span class="av">${Array.from(m.name).slice(-1).join('')}</span>
-              <span class="nm"><a href=${href(`/ledger?member=${encodeURIComponent(m.name)}`)} data-link title="在台账里按这个人筛选">${m.name}</a>${m.name === this.me ? html`<span class="me-tag">我</span>` : ''}</span>
+              <span class="nm"><a href=${href(`/ledger?member=${encodeURIComponent(m.name)}`)} data-link title="在台账里按这个人筛选">${m.name}</a>${m.name === this.me ? html`<span class="me-tag">我</span>` : ''}${m.employee ? html`<small class="identity-note">${t(employeeCaption(m.employee))}</small>${m.employee.verifiedAt ? html`<small class="identity-note">${t(`姓名同步于 ${fmtDate(m.employee.verifiedAt)}`)}</small>` : ''}` : ''}</span>
               <span>${m.stageObj ? t(`${m.stageObj.code} ${m.stageObj.name}`) : '未开始'}</span>
               <span class="num">${m.msgCount}<span class="u">条</span></span>
               <span class="num">${m.artifactIds.length}<span class="u">份</span></span>

+ 107 - 0
test/wecom-directory.test.js

@@ -0,0 +1,107 @@
+import test from 'node:test';
+import assert from 'node:assert/strict';
+import crypto from 'node:crypto';
+import { createWecomDirectory } from '../server/identity/wecom-directory.mjs';
+import { attachWecomEmployeeHandlers } from '../server/identity/employee-handlers.mjs';
+import { buildModel } from '../src/core/kfs/build.js';
+import { employeeCaption } from '../src/core/kfs/employee.js';
+
+const canonical = (v) => JSON.stringify(v);
+const hash = (v) => crypto.createHash('sha256').update(v).digest('hex');
+const account = (bot = 'test-bot') => ({ platform: 'wecom', identifierType: 'wecom_aibot_sender', scopeId: bot, accountId: 'opaque-test-sender' });
+const config = () => ({ enabled: true, corpId: 'test-corp', secret: 'fixture-only-secret', organizationName: '测试企业', bots: { 'test-bot': { mappingVerified: true, senderIdType: 'mapped', userIds: { 'opaque-test-sender': 'directory-user-1' } } } });
+const blank = () => Object.fromEntries(['session', 'artifact', 'project', 'opportunity', 'insight', 'source'].map((k) => [k, []]));
+function fixtureApi({ user = {}, departmentError = false, tokenError = false } = {}) {
+  const calls = [];
+  const fetchImpl = async (url, options) => {
+    calls.push({ url, options }); const path = new URL(url).pathname;
+    const body = path.endsWith('/gettoken') ? tokenError ? { errcode: 40001, errmsg: 'contains fixture-only-secret' } : { errcode: 0, access_token: 'fixture-access-token', expires_in: 7200 }
+      : path.endsWith('/user/get') ? { errcode: 0, userid: 'directory-user-1', name: '测试成员甲', status: 1, alias: '测试昵称', position: '产品经理', department: [3], mobile: 'must-not-be-public', email: 'must-not-be-public', ...user }
+        : departmentError ? { errcode: 48002 } : { errcode: 0, department: [{ id: 3, name: '测试产品部' }] };
+    return { ok: true, json: async () => body };
+  };
+  return { fetchImpl, calls };
+}
+function directory(api = fixtureApi(), value = config()) {
+  return { ...api, resolver: createWecomDirectory({ config: value, hash, canonical, fetchImpl: api.fetchImpl, now: () => Date.parse('2026-11-03T00:00:00Z') }) };
+}
+test('aibot sender requires verified scope and explicit UserID mapping before any network request', async () => {
+  for (const cfg of [null, {}, { ...config(), bots: {} }, { ...config(), bots: { 'test-bot': { senderIdType: 'directory_userid' } } }]) {
+    const f = directory(fixtureApi(), cfg);
+    await assert.rejects(() => f.resolver.resolve(account()), /WECOM_(?:DIRECTORY_NOT_CONFIGURED|SENDER_MAPPING_REQUIRED)/);
+    assert.equal(f.calls.length, 0);
+  }
+});
+test('directory supplies name and available business fields, without returning credentials or contact identifiers', async () => {
+  const f = directory(), p = await f.resolver.resolve(account());
+  assert.equal(p.name, '测试成员甲'); assert.equal(p.displayName, '测试昵称');
+  assert.deepEqual(p.departmentNames, ['测试产品部']); assert.equal(p.position, '产品经理');
+  assert.equal(p.nameVerified, true); assert.equal(p.source, 'wecom_directory');
+  for (const secret of ['fixture-only-secret', 'fixture-access-token', 'directory-user-1', 'must-not-be-public']) assert.ok(!JSON.stringify(p).includes(secret));
+  await f.resolver.resolve(account()); assert.equal(f.calls.length, 3, 'one member lookup is reused within a sync');
+  assert.ok(f.calls.every((c) => c.options.redirect === 'error'));
+});
+test('optional department permission does not discard a verified name; inactive or mismatched members are rejected', async () => {
+  const f = directory(fixtureApi({ departmentError: true }));
+  assert.equal((await f.resolver.resolve(account())).name, '测试成员甲');
+  for (const user of [{ userid: 'another-user' }, { status: 2 }, { name: '' }]) await assert.rejects(() => directory(fixtureApi({ user })).resolver.resolve(account()), { code: 'WECOM_DIRECTORY_MEMBER_UNAVAILABLE' });
+  await assert.rejects(() => directory(fixtureApi({ tokenError: true })).resolver.resolve(account()), (e) => e.code === 'WECOM_DIRECTORY_API_ERROR' && !e.message.includes('fixture-only-secret'));
+});
+
+async function appFixture() {
+  const api = fixtureApi(), settings = { wecomDirectory: config(), unrelatedSetting: 'preserved' };
+  const resolver = directory(api, settings.wecomDirectory).resolver;
+  const profile = await resolver.resolve(account());
+  settings.wecomEmployees = { [resolver.accountKey(account())]: profile };
+  const sessions = [
+    { objectId: 'session1', studentName: '历史占位名', workshopKey: 'workshop1', meta: { ownerId: 'service1', attribution: { participantAccount: account() } } },
+    { objectId: 'session2', studentName: '历史占位名', workshopKey: 'workshop2', meta: { ownerId: 'service1', attribution: { participantAccount: account() } } },
+  ];
+  const raw = { ...blank(), session: sessions.map((s, i) => ({ objectId: s.objectId, studentName: s.studentName, meta: { participantKey: 'workshop-participant-' + i } })),
+    artifact: [{ objectId: 'a', ownerName: '历史占位名', ownerSession: 'session1', rawText: '正文不能修改' }], project: [{ objectId: 'p', ownerName: '历史占位名', ownerSession: 'session2' }] };
+  const writes = [], audits = [];
+  const runtime = { objectId: 'runtime-id', key: 'runtime', value: structuredClone(settings) };
+  const store = { get: async (kind, id) => structuredClone(sessions.find((s) => s.objectId === id)), list: async () => [structuredClone(runtime)], lock: async () => {}, update: async (kind, id, patch) => { writes.push({ kind, id, patch }); Object.assign(runtime, patch); } };
+  const fail = (code) => { throw Object.assign(new Error(code), { code }); };
+  const app = { handlers: { ksfGetPublicBoard: async () => structuredClone(raw) }, fail, hash, canonical,
+    need: (actor, scope) => { if (!actor) fail('AUTH_REQUIRED'); if (!actor.scopes.some((s) => s === '*' || s === scope)) fail('FORBIDDEN'); },
+    operation: async (actor, name, p, fn) => { audits.push({ name, p }); return fn(store); } };
+  attachWecomEmployeeHandlers(app, { store, settings, fetchImpl: api.fetchImpl });
+  return { app, raw, sessions, writes, audits, settings, api, runtime, actor: { id: 'admin', scopes: ['*'] } };
+}
+test('public overlay supplies directory names and stable identity across workshops while preserving history', async () => {
+  const f = await appFixture(), before = structuredClone(f.raw), result = await f.app.handlers.ksfGetPublicBoard({});
+  assert.equal(result.artifact[0].ownerName, '测试成员甲'); assert.equal(result.project[0].ownerName, '测试成员甲');
+  assert.equal(result.session[0].employee.key, result.session[1].employee.key);
+  const db = buildModel(result); assert.equal(db.members.length, 1); assert.equal(db.members[0].name, '测试成员甲');
+  assert.equal(db.members[0].artifactIds.length, 1); assert.equal(db.members[0].projectIds.length, 1);
+  assert.ok(employeeCaption(db.members[0].employee).includes('测试产品部')); assert.ok(employeeCaption(db.members[0].employee).includes('产品经理'));
+  assert.deepEqual(f.raw, before); assert.equal(f.writes.length, 0);
+  for (const value of ['fixture-only-secret', 'opaque-test-sender', 'directory-user-1', 'must-not-be-public']) assert.ok(!JSON.stringify(result).includes(value));
+  const requests = f.api.calls.length; await f.app.handlers.ksfGetPublicBoard({}); assert.equal(f.api.calls.length, requests, 'public reads never call WeCom');
+});
+test('unresolved senders stay distinct by stable account and use actual channel display name when available', async () => {
+  const f = await appFixture(); const registry = f.settings.wecomEmployees; f.settings.wecomEmployees = {};
+  f.sessions[0].meta.attribution.participantAccount.accountName = '已取得的企微显示名';
+  const result = await f.app.handlers.ksfGetPublicBoard({});
+  assert.equal(result.session[0].studentName, '已取得的企微显示名'); assert.equal(result.session[0].employee.nameVerified, false);
+  assert.ok(result.session[0].employee.key.startsWith('channel-employee:'));
+  f.settings.wecomDirectory.bots['test-bot'].userIds['opaque-test-sender'] = 'changed-directory-user';
+  f.settings.wecomEmployees = registry;
+  assert.equal((await f.app.handlers.ksfGetPublicBoard({})).session[0].employee.name, null);
+});
+test('sync is scoped, dry by default, rejects caller names, and updates only the private registry with an audit', async () => {
+  const f = await appFixture(), sync = f.app.handlers.ksfSyncWecomEmployee;
+  await assert.rejects(() => sync({ sessionIds: ['session1'] }, null), { code: 'AUTH_REQUIRED' });
+  await assert.rejects(() => sync({ sessionIds: ['session1'], name: '伪造姓名' }, f.actor), { code: 'VALIDATION_FAILED' });
+  await assert.rejects(() => sync({ sessionIds: ['session1'] }, { id: 'other-service', scopes: ['identity:write'] }), { code: 'FORBIDDEN' });
+  const dry = await sync({ sessionIds: ['session1', 'session2'] }, f.actor); assert.equal(dry.applied, false); assert.equal(dry.accountsChecked, 1); assert.equal(f.writes.length, 0);
+  const result = await sync({ sessionIds: ['session1', 'session2'], requestId: 'identity-test-1', apply: true }, f.actor);
+  assert.equal(result.applied, true); assert.equal(f.writes.length, 1); assert.equal(f.writes[0].kind, 'KsfRuntimeConfig'); assert.equal(f.audits[0].name, 'wecom-employee-sync');
+  assert.equal(f.runtime.value.unrelatedSetting, 'preserved'); assert.equal(f.sessions[0].studentName, '历史占位名');
+});
+test('same-name employees are kept separate even in one department, without losing report attribution', () => {
+  const employee = (id) => ({ key: 'employee:' + id.repeat(64), name: '测试同名', source: 'wecom_directory', nameVerified: true, departmentNames: ['产品部'] });
+  const raw = { ...blank(), session: [{ objectId: 's1', employee: employee('a'), meta: { participantKey: 'p1' } }, { objectId: 's2', employee: employee('b'), meta: { participantKey: 'p2' } }], artifact: [{ objectId: 'a', ownerSession: 's2' }] };
+  const db = buildModel(raw); assert.equal(db.members.length, 2); assert.equal(db.members.find((m) => m.name.includes('bbbbbbbb')).artifactIds.length, 1);
+});

+ 50 - 0
test/wecom-identity-deploy.test.js

@@ -0,0 +1,50 @@
+import test from 'node:test';
+import assert from 'node:assert/strict';
+import fs from 'node:fs';
+import os from 'node:os';
+import path from 'node:path';
+import crypto from 'node:crypto';
+import { buildIdentityFunctions } from '../server/identity/build-release.mjs';
+import { manage } from '../server/identity/manage.mjs';
+const sha = (v) => crypto.createHash('sha256').update(v).digest('hex');
+function setup(t) {
+  const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'ksf-identity-test-')); t.after(() => fs.rmSync(dir, { recursive: true, force: true }));
+  const base = { name: 'ksfGetPublicBoard', path: '/ksfGetPublicBoard', objectId: 'public-id', ACL: { '*': { read: true } }, code: 'function bootstrap(){attachWorkshopHandlers(app,{store,data:DATA});}\nfunction handler(request,response){return runDatabaseFunction("ksfGetPublicBoard",request,response,{});}' };
+  const built = buildIdentityFunctions(base);
+  for (const entry of built) fs.writeFileSync(path.join(dir, entry.name + '.js'), entry.code);
+  fs.writeFileSync(path.join(dir, 'manifest.json'), JSON.stringify({ version: 'ksf.wecom-identity.20261103.v1', functions: built.map(({ code, ...r }) => r) }));
+  const rows = [structuredClone(base)], calls = [];
+  const request = async (method, route, body) => {
+    calls.push({ method, route });
+    if (method === 'GET' && route.includes('?')) { const name = JSON.parse(new URL('https://test' + route).searchParams.get('where')).name; return { results: structuredClone(rows.filter((r) => r.name === name)) }; }
+    const id = route.split('/').at(-1), row = rows.find((r) => r.objectId === id);
+    if (method === 'GET') { assert.ok(row); return structuredClone(row); }
+    if (method === 'POST') { const created = { objectId: 'sync-id', ...body }; rows.push(created); return { objectId: created.objectId }; }
+    if (method === 'PUT') { assert.ok(row); Object.assign(row, body); return {}; }
+    if (method === 'DELETE') { rows.splice(rows.indexOf(row), 1); return {}; }
+    throw Error('unexpected request');
+  };
+  const run = (args) => manage({ args, request, releaseDir: dir });
+  return { dir, base, built, rows, calls, run };
+}
+test('production overlay builds exact executable entries while rejecting unknown base shapes', () => {
+  assert.throws(() => buildIdentityFunctions({ name: 'other', code: '' }), /UNEXPECTED_PUBLIC_FUNCTION/);
+});
+test('function deployment is read-only by default, checks exact baseline, repeats without writes and restores old public code', async (t) => {
+  const f = setup(t), plan = await f.run(['deploy']); assert.equal(plan.readOnly, true); assert.ok(f.calls.every((c) => c.method === 'GET'));
+  const applied = await f.run(['deploy', '--apply', '--receipt-dir', f.dir]); assert.equal(applied.applied, true); assert.equal(f.rows.length, 2);
+  const before = f.calls.length; assert.equal((await f.run(['deploy', '--apply', '--receipt-dir', f.dir])).unchanged, true); assert.ok(f.calls.slice(before).every((c) => c.method === 'GET'));
+  await f.run(['rollback', '--receipt', applied.receipt]); assert.notEqual(sha(f.rows[0].code), sha(f.base.code));
+  await f.run(['rollback', '--receipt', applied.receipt, '--apply']); assert.equal(f.rows.length, 1); assert.equal(f.rows[0].code, f.base.code);
+});
+test('unknown function changes and malformed flags stop before any writes', async (t) => {
+  const f = setup(t); f.rows[0].code += '/*newer production revision*/';
+  await assert.rejects(() => f.run(['deploy', '--apply', '--receipt-dir', f.dir]), { code: 'FUNCTION_BASELINE_MISMATCH' });
+  await assert.rejects(() => f.run(['deploy', '--apply', '--unexpected']), { code: 'USAGE' });
+  assert.ok(f.calls.every((c) => c.method === 'GET'));
+});
+test('rollback refuses concurrent edits before changing either function', async (t) => {
+  const f = setup(t), applied = await f.run(['deploy', '--apply', '--receipt-dir', f.dir]); f.rows[0].code += '/*concurrent*/'; const before = f.calls.length;
+  await assert.rejects(() => f.run(['rollback', '--receipt', applied.receipt, '--apply']), { code: 'CONCURRENT_FUNCTION_CHANGE' });
+  assert.ok(f.calls.slice(before).every((c) => c.method === 'GET')); assert.equal(f.rows.length, 2);
+});