ソースを参照

feat: ask once and persist workshop employee display names

pengzhikang 1 日 前
親
コミット
a68bfe1e81

+ 58 - 0
docs/employee-name-patch-20261103.md

@@ -0,0 +1,58 @@
+# 运维001:看板称呼补丁
+
+目标:员工首次资料入库或上传前,Skill询问希望公开显示的姓名/昵称;按稳定员工键保存并复用,明确跳过也记住。本人提供的称呼不等于企微通讯录实名。本补丁不需要CorpID、通讯录Secret或发送者映射。
+
+## 本次范围
+
+- Skill更新6个已有文件:SKILL.md、references/identity.md、references/workshop-tasks.md、scripts/ksf-workshop.mjs、scripts/ksf-secure.mjs、FILES.sha256。其他5个文件保持原字节。凭据绑定、员工/Agent档案、会话索引及状态目录原样保留。
+- 后端仅更新ksfGetPublicBoard、新增ksfEmployeeDisplayName;保留Start/Save/Sync/Read四个既有函数。新增函数不是公开写接口;服务须data:read,保存还须ingest:write,并核对已有会话的服务owner/员工/Agent。
+- 静态仅更新src/core/kfs/employee.js一句来源说明。不发布首页或其他页面。
+- 运行时只有员工明确提供称呼/明确跳过后才写workshopDisplayNames及操作审计。报告、项目、会话归属和原文不迁移、不重存。
+
+## 1. 核验与部署后端
+
+解压到站点外独立目录;在解包根目录执行。外层清单验证全部通过后再继续:
+
+```sh
+sha256sum -c FILES.sha256
+export KSF_PLATFORM_CONFIG=/opt/ksf-identity-private/platform.json
+node server/identity/manage.mjs deploy-employee-name
+node server/identity/manage.mjs deploy-employee-name --apply --receipt-dir /opt/ksf-identity-private/receipts
+```
+
+平台连接沿用已有0600私有文件,不打印内容。部署默认只读,apply前必须匹配现网r5公开函数SHA256 `d4c7e2923a13b56b60821f02c8ca4ab472fca3664cde8fcdbf3f621e51047b23`。新增函数先部署,公开读函数最后更新;任何基线差异停止,不强行覆盖。保留部署回执并独立回读两个函数哈希,其他四函数应保持不变。匿名调用新函数应AUTH_REQUIRED。
+
+## 2. 发布唯一静态文件
+
+```sh
+node scripts/publish-employee-name.mjs --target-dir /opt/kangshifu-web
+node scripts/publish-employee-name.mjs --target-dir /opt/kangshifu-web --apply
+```
+
+只允许匹配已发布r5的employee.js,未知改动停止;工具保存站点外备份并回读。GET公开URL `/src/core/kfs/employee.js` 核对与deploy/employee-name-static.json的raw SHA256一致,首页和其他文件保持不变。本工具无网络请求。
+
+## 3. 安装目标Skill
+
+将包内skills/ksf-workshop-runtime与scripts/install-workshop-skill.mjs按原相对目录复制到agent-node-u17879018108内的站点外目录(例如/opt/data/ksf-employee-name-patch-20261103),然后在包根目录:
+
+```sh
+node scripts/install-workshop-skill.mjs --check
+node scripts/install-workshop-skill.mjs
+```
+
+目标固定 `/opt/data/skills/ksf-workshop-runtime`,filesChecked=11,changed应恰为本单列出的6项。安装器识别当前employee-read补丁基线,未知版本停止。核对目标FILES.sha256 10/10通过,credentialBindingPreserved/stateDirectoryPreserved等均true,会话索引btrFqkz0yw.json仍为1129B及SHA256 `24ef684e53f2d93421da79aab3ab8382419741f8b33fa530c38a7b88311110ee`。不改网关、不重启;下一次Skill加载生效。
+
+## 4. 真实消息验收
+
+转发配套《王刚001-看板称呼最小验收》,由当前员工本人回答想用的称呼。运维不代选测试用户称呼。首次只保存/回读称呼并检查公开看板即可,不需要新建会话或重传报告。真实消息与生产浏览器验收前,仍记browserVerified=false,不声明已上线生效。
+
+## 回退
+
+```sh
+node server/identity/manage.mjs rollback --receipt /绝对路径/本次部署回执.json
+node server/identity/manage.mjs rollback --receipt /绝对路径/本次部署回执.json --apply
+node scripts/publish-employee-name.mjs --target-dir /opt/kangshifu-web --rollback /绝对路径/静态备份.json
+node scripts/publish-employee-name.mjs --target-dir /opt/kangshifu-web --rollback /绝对路径/静态备份.json --apply
+```
+
+Skill从安装回执backup恢复changed的6个文件;三处均先校验当前仍为本次版本,存在并发修改则停止。回退代码不会删除已保存的称呼注册表,也不触碰业务报告;旧版看板暂不投影自报称呼。

+ 52 - 0
docs/employee-name-validation-20261103.json

@@ -0,0 +1,52 @@
+{
+  "ok": true,
+  "productionWrites": 0,
+  "fixturesOnly": true,
+  "generatedCode": {
+    "baselineSha256": "d4c7e2923a13b56b60821f02c8ca4ab472fca3664cde8fcdbf3f621e51047b23",
+    "functions": [
+      {
+        "name": "ksfGetPublicBoard",
+        "sha256": "195d758efe9b2da15d4439d7724c1d0e9fa2e5ee82190a3e5c70e0f716e8532f"
+      },
+      {
+        "name": "ksfEmployeeDisplayName",
+        "sha256": "6aae3c902dbe5b79fb3402591601a5d8fcf9b034589a95e0298a622217ac3b51"
+      }
+    ],
+    "realApplicationOperation": true,
+    "readSaveRead": true,
+    "idempotency": true,
+    "conflictRejected": true,
+    "publicNickname": true,
+    "historyUnchanged": true,
+    "databaseTablesWritten": [
+      "KsfRuntimeConfig",
+      "KsfOperation"
+    ]
+  },
+  "skillUpgrade": {
+    "fromCommit": "6a0edbb",
+    "filesChecked": 11,
+    "changed": [
+      "references/workshop-tasks.md",
+      "SKILL.md",
+      "scripts/ksf-secure.mjs",
+      "references/identity.md",
+      "scripts/ksf-workshop.mjs",
+      "FILES.sha256"
+    ],
+    "credentialBindingPreserved": true,
+    "stateDirectoryPreserved": true,
+    "repeatInstallUnchanged": true
+  },
+  "deployed": false,
+  "browserVerified": false,
+  "checks": {
+    "tests": 114,
+    "testsPassed": 114,
+    "staticChecks": 13,
+    "staticChecksPassed": 13,
+    "skillValidator": "passed"
+  }
+}

+ 23 - 0
docs/wanggang-employee-name-test-20261103.md

@@ -0,0 +1,23 @@
+# 王刚001:看板称呼最小验收
+
+更新后重新加载 `/opt/data/skills/ksf-workshop-runtime/SKILL.md`,核验FILES.sha256。用当前真实新消息执行以下流程,不使用运维凭据,不调用employee-read,也不要求通讯录配置。
+
+1. 无参数调用ksf_register_current_employee,核对当前员工键、Agent键与既有会话btrFqkz0yw索引。归属不符就停,不换键。不要打印原始账号。
+2. 用employee-name只读查询现有称呼。若promptState=not_asked,且没有可用称呼,直接问当前员工:**“这份成果会显示在公开看板上,希望怎么称呼你?姓名或昵称都可以,也可以先跳过。”** 等本人回答,不自行替他选名。已有称呼就复用;此前明确跳过不再追问。若本条消息已经明确给出本人称呼,可直接用它。
+3. 本人回复后,重新登记并核对回复发送者,仍用本人会话:保存displayName或skip:true,再用另一个requestId省略这两字段回读。三次操作使用不同requestId;保存出错不自动换键重试。输入/回执放/opt/data/下私有目录(0700/0600)。
+
+```json
+{"sessionId":"btrFqkz0yw","participant":{"profileKey":"本条消息工具实际返回的完整员工键"}}
+```
+
+保存时在上述对象中加 `"displayName":"本人实际回答"`;明确跳过则加 `"skip":true`,两者互斥。
+
+```sh
+node /opt/data/skills/ksf-workshop-runtime/scripts/ksf-secure.mjs employee-name --agent-profile <本条消息实际返回的完整Agent键> --input /opt/data/私有目录/任务.json --request-id <本次操作稳定标记>
+```
+
+成功记录entryResult.result的displayName/promptState/source/nameVerified/persisted。本人称呼应source=self_reported、nameVerified=false,不能汇报为“企微实名获取成功”。读取应readOnly=true、databaseWrites=0;保存应persisted=true、businessRecordsChanged=0。
+
+4. 读取公开看板,核对原会话、原报告/项目employee.displayName等于本人回答,两个既有工作坊仍归同一稳定employee.key。正常刷新成员页应显示昵称,并标“本人提供的称呼 · 未经通讯录核验”。只能API核对时明确browserVerified=false。后续正常上传前查询同一称呼,直接复用不再问;无需为本次测试上传新报告。
+
+仅回报:问了什么、本人答复、保存/回读是否一致、公开看板是否显示、是否重复询问。不要重跑旧工作坊、重存报告或改员工档案/会话索引。用户跳过时如实记录跳过,不声称已经有姓名。昵称只改变显示,归属由可信发送者稳定键决定,同名不合并。

+ 4 - 3
scripts/install-workshop-skill.mjs

@@ -60,6 +60,7 @@ const expectedUploadBaseline={
 const expectedEmployeeR1={"references/workshop-tasks.md":"032a2502cd7a74a33f800638fd2a3c3568233b7118f5a9adbfea0446ae33f30d","references/write-tasks.md":"197a02db39a78bd14803c27b455a51681b6ec32f8d50e69d6e32486a2e9fe2cc","scripts/ksf-secure.mjs":"1fe19bf41e3b57aa78061a15d04d346b3a4c189452d15e98434c35a727bb81c1","runtime-lock.json":"2605cbe8fd7be43b5969a4991f978dc0768dee5b4d65d18f7af15f75e9004a79","references/identity.md":"619568567035417f018f25b73ceb32f704274a3c5668d3b591215b41acce5ae3","scripts/ksf-identity-bridge.mjs":"6ba3f0cb6527bc4a0a6218a898472ac65aa3dd9f3f57b597d77efcee641a7540","scripts/ksf.mjs":"7ef128f7d022bfbc15c0ec813bfd87995c6297c6efe407a563c840f014aba477","scripts/ksf-identity.mjs":"9224ef89a96425890d54961fda1f4fcae3403aa7c753fe1d70ac62082f2ff77e","scripts/ksf-workshop.mjs":"95d05ac21310b7ccdd204cd5d01b4dcb7fa6944694acda089f1ff32eacda4fc7","SKILL.md":"b3161c29cf0c7b6c8a6ce1f0308d99c60acab801ca7affc52d39c4ea1c61e27e"};
 function plain(p){const s=fs.lstatSync(p);if(!s.isFile()||s.isSymbolicLink())fail('NOT_PLAIN_FILE');}
 function stateSnapshot(root){if(!fs.existsSync(root))return{};const result={};function walk(dir){if(fs.lstatSync(dir).isSymbolicLink())fail('SYMLINK_STATE_DIRECTORY');for(const e of fs.readdirSync(dir,{withFileTypes:true})){const file=path.join(dir,e.name);if(e.isSymbolicLink())fail('SYMLINK_STATE_DIRECTORY');if(e.isDirectory())walk(file);else{plain(file);result[path.relative(root,file)]=hash(fs.readFileSync(file));}}}walk(root);return result;}
+const expectedEmployeeRead={"references/write-tasks.md":"197a02db39a78bd14803c27b455a51681b6ec32f8d50e69d6e32486a2e9fe2cc","scripts/ksf-workshop.mjs":"1e3176196a5582a50678db5eb55c20dad8b5f34e41ebeac9a6861d263ec950d8","runtime-lock.json":"2605cbe8fd7be43b5969a4991f978dc0768dee5b4d65d18f7af15f75e9004a79","SKILL.md":"27a95c50a01d191368cebb6adf94f824eb81f2278ad79441d093f064d35ec61d","references/identity.md":"2fb2df62c72c948a19516b74213c050ab296aecc14018125958aa5a006395e22","scripts/ksf-secure.mjs":"4bc9869d0e35f550315869754fa11d64f8316115960df0411bd1eaebaba658bf","scripts/ksf-identity-bridge.mjs":"6ba3f0cb6527bc4a0a6218a898472ac65aa3dd9f3f57b597d77efcee641a7540","scripts/ksf.mjs":"7ef128f7d022bfbc15c0ec813bfd87995c6297c6efe407a563c840f014aba477","references/workshop-tasks.md":"822b63029c65078cf42d21304aa704ad00fbcb56ca52754e66ce3c04013fc7be","scripts/ksf-identity.mjs":"9224ef89a96425890d54961fda1f4fcae3403aa7c753fe1d70ac62082f2ff77e"};
 export function installWorkshopSkill(source,destination,{apply=true}={}){
  source=path.resolve(source);destination=path.resolve(destination);
  if(fs.realpathSync(destination)!==destination)fail('SYMLINK_DESTINATION');
@@ -75,14 +76,14 @@ export function installWorkshopSkill(source,destination,{apply=true}={}){
  if(JSON.stringify(entries.map(x=>x.name).sort())!==JSON.stringify(expectedNames))fail('UNEXPECTED_SKILL_FILES');
  const changes=[];
  for(const e of entries){
-  if(fs.existsSync(e.target)){plain(e.target);const before=fs.readFileSync(e.target),current=hash(before);if(current===e.hash)continue;if(![expectedBefore[e.name],expectedWorkshop[e.name],expectedTwoAgents[e.name],expectedRegistry[e.name],expectedAibot[e.name],expectedCurrentIntegration[e.name],expectedUploadBaseline[e.name],expectedEmployeeR1[e.name]].includes(current))fail('TARGET_HAS_UNEXPECTED_CONTENT');changes.push({...e,before});}
+  if(fs.existsSync(e.target)){plain(e.target);const before=fs.readFileSync(e.target),current=hash(before);if(current===e.hash)continue;if(![expectedBefore[e.name],expectedWorkshop[e.name],expectedTwoAgents[e.name],expectedRegistry[e.name],expectedAibot[e.name],expectedCurrentIntegration[e.name],expectedUploadBaseline[e.name],expectedEmployeeR1[e.name],expectedEmployeeRead[e.name]].includes(current))fail('TARGET_HAS_UNEXPECTED_CONTENT');changes.push({...e,before});}
   else{if(expectedBefore[e.name])fail('BASELINE_FILE_MISSING');changes.push({...e,before:null});}
  }
  const manifest={name:'FILES.sha256',origin:path.join(source,'FILES.sha256'),target:path.join(destination,'FILES.sha256')};
  plain(manifest.origin);manifest.hash=hash(fs.readFileSync(manifest.origin));
  if(fs.existsSync(manifest.target)){
   plain(manifest.target);const before=fs.readFileSync(manifest.target),current=hash(before);
-  if(current!==manifest.hash){if(!['6d0ab7383ef7556c820bcb322fd8e853273273b948f79e64113dc6050e6b2055','edcafe16e164eda1e6b3b8764b2ff26eee51b697516824138561894c61d79c3f'].includes(current))fail('TARGET_MANIFEST_HAS_UNEXPECTED_CONTENT');changes.push({...manifest,before});}
+  if(current!==manifest.hash){if(!['25f7527814a0096000015ef3c45d48d0789d1f69e292eb4cca385bd1211eacf6','6d0ab7383ef7556c820bcb322fd8e853273273b948f79e64113dc6050e6b2055','edcafe16e164eda1e6b3b8764b2ff26eee51b697516824138561894c61d79c3f'].includes(current))fail('TARGET_MANIFEST_HAS_UNEXPECTED_CONTENT');changes.push({...manifest,before});}
  }else changes.push({...manifest,before:null});
  entries.push(manifest);
  if(!apply)return{ok:true,readOnly:true,changed:changes.map(x=>x.name),destination,filesChecked:entries.length,credentialBindingPreserved:true,stateDirectoryPreserved:true};
@@ -98,7 +99,7 @@ export function installWorkshopSkill(source,destination,{apply=true}={}){
   for(const e of entries)if(hash(fs.readFileSync(e.target))!==e.hash)fail('INSTALLED_HASH_MISMATCH');
   if(hash(fs.readFileSync(binding))!==bindingHash)fail('CREDENTIAL_BINDING_CHANGED');
   if(JSON.stringify(stateSnapshot(state))!==JSON.stringify(beforeState))fail('IDENTITY_PROFILES_CHANGED');
-  return{ok:true,readOnly:false,changed:changes.map(x=>x.name),destination,backup,credentialBindingPreserved:true,agentProfilePreserved:true,participantProfilesPreserved:true,stateDirectoryPreserved:true,legacyEntryPreserved:true,r7Modified:false,gatewayModified:false,discoveryVerified:false,note:'请重新加载技能;已有档案直接复用。实名还依赖服务端扩展、通讯录配置及真实消息验收,安装成功不代表已取得实名。'};
+  return{ok:true,readOnly:false,changed:changes.map(x=>x.name),destination,backup,credentialBindingPreserved:true,agentProfilePreserved:true,participantProfilesPreserved:true,stateDirectoryPreserved:true,legacyEntryPreserved:true,r7Modified:false,gatewayModified:false,discoveryVerified:false,note:'请重新加载技能;已有档案直接复用。自报称呼需要配套后端及真实消息验收;不依赖通讯录权限,也不等于已取得实名。'};
  }catch(e){for(const item of done.reverse()){if(item.before)fs.writeFileSync(item.target,item.before);else fs.unlinkSync(item.target);}throw e;}
 }
 if(process.argv[1]&&path.resolve(process.argv[1])===fileURLToPath(import.meta.url)){

+ 41 - 0
scripts/package-employee-name-patch.mjs

@@ -0,0 +1,41 @@
+import fs from 'node:fs';
+import path from 'node:path';
+import crypto from 'node:crypto';
+import { fileURLToPath } from 'node:url';
+import { buildEmployeeName } from '../server/identity/build-employee-name.mjs';
+
+const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
+const sha = v => crypto.createHash('sha256').update(v).digest('hex');
+const args = process.argv.slice(2);
+if (args.length !== 4 || args[0] !== '--base' || args[2] !== '--out' || !path.isAbsolute(args[3]) || fs.existsSync(args[3])) throw Error('Use --base PRIVATE_R5_SNAPSHOT --out NEW_ABSOLUTE_DIRECTORY');
+const base = JSON.parse(fs.readFileSync(args[1], 'utf8'));
+if (base.objectId !== 'inPfC51rvn' || sha(base.code) !== 'd4c7e2923a13b56b60821f02c8ca4ab472fca3664cde8fcdbf3f621e51047b23') throw Error('EXPECTED_DEPLOYED_R5_BASE');
+const built = buildEmployeeName(base), skill = 'skills/ksf-workshop-runtime';
+const files = fs.readFileSync(path.join(root, skill, 'FILES.sha256'), 'utf8').trim().split(/\r?\n/).map(line => {
+  const m = line.match(/^([a-f0-9]{64})  ([\w./-]+)$/);
+  if (!m || m[2].split('/').includes('..') || sha(fs.readFileSync(path.join(root, skill, m[2]))) !== m[1]) throw Error('SKILL_MANIFEST_MISMATCH');
+  return skill + '/' + m[2];
+});
+if (new Set(files).size !== 10) throw Error('UNEXPECTED_SKILL_FILES');
+files.push(skill + '/FILES.sha256', 'scripts/install-workshop-skill.mjs', 'scripts/build-workshop-skill.mjs',
+  'scripts/package-employee-name-patch.mjs', 'scripts/publish-employee-name.mjs',
+  'server/identity/employee-name.mjs', 'server/identity/wecom-directory.mjs', 'server/identity/build-employee-name.mjs', 'server/identity/manage.mjs',
+  'docs/employee-name-patch-20261103.md', 'docs/wanggang-employee-name-test-20261103.md', 'docs/employee-name-validation-20261103.json');
+const out = args[3]; fs.mkdirSync(out, { recursive: true });
+for (const file of files) {
+  const input = path.join(root, file), stat = fs.lstatSync(input);
+  if (!stat.isFile() || stat.isSymbolicLink()) throw Error('NOT_PLAIN_PACKAGE_FILE');
+  const target = path.join(out, file); fs.mkdirSync(path.dirname(target), { recursive: true }); fs.copyFileSync(input, target, fs.constants.COPYFILE_EXCL);
+}
+const write = (relative, bytes) => {
+  const target = path.join(out, relative); fs.mkdirSync(path.dirname(target), { recursive: true }); fs.writeFileSync(target, bytes, { flag: 'wx' }); files.push(relative);
+};
+for (const entry of built.functions) write('server/identity/name-dist/' + entry.name + '.js', entry.code);
+write('server/identity/name-dist/manifest.json', JSON.stringify({ ...built, functions: built.functions.map(({ code, ...m }) => m) }, null, 2));
+const staticFile = 'src/core/kfs/employee.js', bytes = fs.readFileSync(path.join(root, staticFile));
+write('payload/' + staticFile, bytes);
+write('deploy/employee-name-static.json', JSON.stringify({ release: built.version, path: staticFile,
+  prior: 'd2b312ac7ae6c749279a9c04c9e87e697b86c269e503ab3009c6e1b69c852eb1', sha256: sha(bytes) }, null, 2));
+fs.writeFileSync(path.join(out, 'FILES.sha256'), files.sort().map(file => sha(fs.readFileSync(path.join(out, file))) + '  ' + file).join('\n') + '\n', { flag: 'wx' });
+console.log(JSON.stringify({ ok: true, release: 'ksf.employee-name-patch.20261103.r1', files: files.length + 1, functionsAdded: 1,
+  existingFunctionsChanged: 1, staticFilesChanged: 1, skillChangedFiles: 6, out, deployed: false }));

+ 70 - 0
scripts/publish-employee-name.mjs

@@ -0,0 +1,70 @@
+import fs from 'node:fs';
+import path from 'node:path';
+import crypto from 'node:crypto';
+import { fileURLToPath } from 'node:url';
+
+const FILE = 'src/core/kfs/employee.js', RELEASE = 'ksf.employee-name.20261103.v1';
+const PRIOR = 'd2b312ac7ae6c749279a9c04c9e87e697b86c269e503ab3009c6e1b69c852eb1';
+const sha = b => crypto.createHash('sha256').update(b).digest('hex');
+const fail = code => { throw Object.assign(Error(code), { code }); };
+function plain(file) { const s = fs.lstatSync(file); if (s.isSymbolicLink() || !s.isFile()) fail('NOT_PLAIN_FILE'); return s; }
+function site(targetDir) {
+  if (!path.isAbsolute(targetDir)) fail('ABSOLUTE_TARGET_REQUIRED');
+  const root = path.resolve(targetDir);
+  if (fs.realpathSync(root) !== root || root === path.parse(root).root) fail('UNSAFE_TARGET');
+  for (let p = path.join(root, FILE); p !== path.dirname(p); p = path.dirname(p)) if (fs.lstatSync(p).isSymbolicLink()) fail('UNSAFE_TARGET');
+  plain(path.join(root, FILE)); plain(path.join(root, 'index.html'));
+  return root;
+}
+function replace(file, bytes, mode) {
+  const temp = file + '.name-' + crypto.randomUUID();
+  try { fs.writeFileSync(temp, bytes, { flag: 'wx', mode }); fs.renameSync(temp, file); }
+  finally { if (fs.existsSync(temp)) fs.unlinkSync(temp); }
+}
+export function publishEmployeeName({ sourceDir, targetDir, apply = false, backupFile = null }) {
+  const root = site(targetDir), target = path.join(root, FILE), homepage = sha(fs.readFileSync(path.join(root, 'index.html')));
+  const before = fs.readFileSync(target), current = sha(before), mode = fs.statSync(target).mode & 0o777;
+  let bytes, journal;
+  if (backupFile) {
+    if (!path.isAbsolute(backupFile) || path.resolve(backupFile).startsWith(root + path.sep)) fail('UNSAFE_BACKUP');
+    plain(backupFile); journal = JSON.parse(fs.readFileSync(backupFile));
+    if (journal.release !== RELEASE || journal.root !== root || journal.homepage !== homepage || journal.prior !== PRIOR ||
+        current !== journal.after || sha(Buffer.from(journal.before, 'base64')) !== PRIOR) fail('ROLLBACK_VERSION_CONFLICT');
+    bytes = Buffer.from(journal.before, 'base64');
+  } else {
+    if (!path.isAbsolute(sourceDir) || path.resolve(sourceDir) === root || path.resolve(sourceDir).startsWith(root + path.sep) || root.startsWith(path.resolve(sourceDir) + path.sep)) fail('UNSAFE_SOURCE');
+    const input = path.join(sourceDir, 'payload', FILE); plain(input); bytes = fs.readFileSync(input);
+    const manifest = JSON.parse(fs.readFileSync(path.join(sourceDir, 'deploy/employee-name-static.json')));
+    if (manifest.release !== RELEASE || manifest.path !== FILE || manifest.prior !== PRIOR || manifest.sha256 !== sha(bytes)) fail('PAYLOAD_HASH_MISMATCH');
+    if (![PRIOR, sha(bytes)].includes(current)) fail('UNKNOWN_TARGET_CONTENT');
+  }
+  const changed = current !== sha(bytes), result = { ok: true, readOnly: !apply, release: RELEASE, filesChecked: 1,
+    changes: changed ? [FILE] : [], homepageModified: false, networkRequests: 0, businessWrites: 0, browserVerified: false };
+  if (!apply || !changed) return result;
+  const lock = path.join(path.dirname(root), '.' + path.basename(root) + '.ksf-static.lock');
+  const fd = fs.openSync(lock, 'wx', 0o600); let backup = backupFile;
+  try {
+    if (sha(fs.readFileSync(target)) !== current || sha(fs.readFileSync(path.join(root, 'index.html'))) !== homepage) fail('CONCURRENT_CHANGE');
+    if (!backupFile) {
+      backup = path.join(path.dirname(root), 'ksf-employee-name-backup-' + crypto.randomUUID() + '.json');
+      fs.writeFileSync(backup, JSON.stringify({ release: RELEASE, root, homepage, prior: current, after: sha(bytes), before: before.toString('base64') }), { flag: 'wx', mode: 0o600 });
+    }
+    replace(target, bytes, mode);
+    if (sha(fs.readFileSync(target)) !== sha(bytes)) fail('READBACK_MISMATCH');
+    return { ...result, backup, filesystemVerified: true };
+  } finally { fs.closeSync(fd); fs.unlinkSync(lock); }
+}
+if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
+  try {
+    const args = process.argv.slice(2), options = { sourceDir: path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..') };
+    const seen = new Set();
+    for (let i = 0; i < args.length; i++) {
+      const flag = args[i]; if (seen.has(flag)) fail('USAGE'); seen.add(flag);
+      if (flag === '--apply') options.apply = true;
+      else if (['--target-dir', '--rollback'].includes(flag) && args[i + 1] && !args[i + 1].startsWith('--')) options[flag === '--target-dir' ? 'targetDir' : 'backupFile'] = args[++i];
+      else fail('USAGE');
+    }
+    if (!options.targetDir) fail('USAGE');
+    console.log(JSON.stringify(publishEmployeeName(options), null, 2));
+  } catch (e) { console.error(JSON.stringify({ ok: false, code: e.code || 'PUBLISH_STOPPED' })); process.exitCode = 1; }
+}

+ 21 - 0
server/identity/build-employee-name.mjs

@@ -0,0 +1,21 @@
+import fs from 'node:fs';
+import path from 'node:path';
+import crypto from 'node:crypto';
+import { fileURLToPath } from 'node:url';
+
+const root = path.dirname(fileURLToPath(import.meta.url));
+const sha = v => crypto.createHash('sha256').update(v).digest('hex');
+export function buildEmployeeName(base) {
+  const hook = 'attachWecomEmployeeHandlers(app,{store,settings});';
+  const call = 'runDatabaseFunction("ksfGetPublicBoard",request,response,';
+  if (base.name !== 'ksfGetPublicBoard' || !base.objectId || typeof base.code !== 'string' ||
+      base.code.split(hook).length !== 2 || base.code.split(call).length !== 2 || base.code.includes('attachEmployeeNameHandlers')) throw Error('UNEXPECTED_R5_BASE');
+  const source = fs.readFileSync(path.join(root, 'employee-name.mjs'), 'utf8').replace(/^import .*;\r?\n/gm, '').replace(/^export /gm, '');
+  const code = source + '\n' + base.code.replace(hook, hook + '\n    attachEmployeeNameHandlers(app,{store,settings});');
+  const functions = [
+    { name: base.name, path: '/ksfGetPublicBoard', objectId: base.objectId, priorSha256: sha(base.code), code },
+    { name: 'ksfEmployeeDisplayName', path: '/ksfEmployeeDisplayName', priorSha256: null,
+      code: code.replace(call, 'runDatabaseFunction("ksfEmployeeDisplayName",request,response,') },
+  ].map(f => { new Function(f.code); return { ...f, sha256: sha(f.code) }; });
+  return { version: 'ksf.employee-name.20261103.v1', functions };
+}

+ 78 - 0
server/identity/employee-name.mjs

@@ -0,0 +1,78 @@
+import { createWecomDirectory } from './wecom-directory.mjs';
+
+/** A self-reported public label; stable channel identity and historical records stay intact. */
+export function attachEmployeeNameHandlers(app, { store, settings, now = () => Date.now() }) {
+  const { need, fail, hash, canonical } = app;
+  const directory = createWecomDirectory({ config: settings.wecomDirectory, hash, canonical });
+  const accountKey = a => a?.platform === 'wecom' && a.identifierType === 'wecom_aibot_sender' && a.scopeId && a.accountId
+    ? hash(canonical(['wecom_aibot_sender', a.scopeId, a.accountId])) : null;
+  const validName = v => typeof v === 'string' && v.trim() && [...v.trim()].length <= 40 && !/[\p{Cc}\p{Cf}]/u.test(v);
+  const preference = key => {
+    const p = settings.workshopDisplayNames?.[key];
+    return p?.version === 'ksf.employee-name.v1' && p.source === 'self_reported' &&
+      (p.promptState === 'provided' && validName(p.displayName) || p.promptState === 'skipped' && p.displayName === null) ? p : null;
+  };
+  const summary = (sessionId, key, account, readOnly) => {
+    const p = preference(key), verified = settings.wecomEmployees?.[key];
+    // A stored directory name takes precedence; no directory/network query is made here.
+    const directoryVerified = verified?.source === 'wecom_directory' && verified.nameVerified === true &&
+      verified.key === directory.expectedEmployeeKey(account) && typeof verified.name === 'string' && verified.name.trim();
+    return { sessionId, readOnly, persisted: !readOnly, businessRecordsChanged: 0,
+      ...(readOnly ? { databaseWrites: 0 } : {}), promptState: directoryVerified ? 'provided' : p?.promptState || 'not_asked',
+      displayName: directoryVerified ? verified.name : p?.displayName || null,
+      source: directoryVerified ? 'wecom_directory' : p?.source || null, nameVerified: !!directoryVerified };
+  };
+  app.handlers.ksfEmployeeDisplayName = async (p, actor) => {
+    need(actor, 'data:read');
+    const hasName = Object.hasOwn(p || {}, 'displayName'), hasSkip = Object.hasOwn(p || {}, 'skip');
+    const writing = hasName || hasSkip;
+    if (!p || typeof p !== 'object' || Array.isArray(p) ||
+        Object.keys(p).some(k => !['sessionId', 'participantProfileKey', 'agentProfileKey', 'displayName', 'skip', 'requestId'].includes(k)) ||
+        typeof p.sessionId !== 'string' || !/^[\w-]{1,40}$/.test(p.sessionId) ||
+        !/^participant-account:[a-f0-9]{64}$/.test(p.participantProfileKey || '') ||
+        !/^agent-account:[a-f0-9]{64}$/.test(p.agentProfileKey || '') || hasName && hasSkip ||
+        hasName && !validName(p.displayName) || hasSkip && p.skip !== true ||
+        writing && (typeof p.requestId !== 'string' || !/^[\p{L}\p{N}_:@.-]{1,100}$/u.test(p.requestId))) fail('VALIDATION_FAILED', '请提供当前员工及会话;称呼须为1至40字,也可明确跳过');
+    if (writing) need(actor, 'ingest:write');
+    const session = await store.get('KfSession', p.sessionId);
+    if (!session) fail('REFERENCE_NOT_FOUND', '会话不存在');
+    if (!actor.scopes.includes('*') && session.meta?.ownerId !== actor.id) fail('FORBIDDEN', '会话不属于当前服务身份');
+    const a = session.meta?.attribution, key = accountKey(a?.participantAccount);
+    if (!session.workshopKey || a?.participantKey !== 'participant:' + hash(canonical([session.workshopKey, p.participantProfileKey])) ||
+        a?.agentKey !== p.agentProfileKey || a.identityBasis !== 'channel_context' || !key ||
+        a.agentAccount?.platform !== 'wecom' || a.agentAccount.accountId !== a.participantAccount.scopeId) fail('IDENTITY_CONFLICT', '员工或数字人与会话不一致');
+    if (!writing) return summary(session.objectId, key, a.participantAccount, true);
+    const result = await app.operation(actor, 'employee-display-name', 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', '运行配置不存在');
+      settings.workshopDisplayNames = runtime.value.workshopDisplayNames || {};
+      const prior = preference(key);
+      const next = hasName ? { version: 'ksf.employee-name.v1', source: 'self_reported', promptState: 'provided',
+        displayName: p.displayName.trim().normalize('NFC'), providedAt: new Date(now()).toISOString() }
+        : prior || { version: 'ksf.employee-name.v1', source: 'self_reported', promptState: 'skipped', displayName: null, providedAt: new Date(now()).toISOString() };
+      const registry = { ...settings.workshopDisplayNames, [key]: next };
+      await s.update('KsfRuntimeConfig', runtime.objectId, { value: { ...runtime.value, workshopDisplayNames: registry } });
+      settings.workshopDisplayNames = registry;
+      return summary(session.objectId, key, a.participantAccount, false);
+    });
+    return result;
+  };
+  const originalBoard = app.handlers.ksfGetPublicBoard;
+  app.handlers.ksfGetPublicBoard = async (p, actor) => {
+    const result = await originalBoard(p, actor), sessions = new Map();
+    for (const kind of ['session', 'artifact', 'project', 'opportunity']) for (const row of result[kind] || []) {
+      if (row.employee?.nameVerified === true) continue;
+      const id = kind === 'session' ? row.objectId : row.sessionRef?.objectId || row.ownerSession || row.sourceSession;
+      if (!id) continue;
+      if (!sessions.has(id)) sessions.set(id, store.get('KfSession', id));
+      const session = await sessions.get(id), key = accountKey(session?.meta?.attribution?.participantAccount);
+      const saved = key && preference(key);
+      if (!saved?.displayName) continue;
+      row.employee = { ...row.employee, key: row.employee?.key || 'channel-employee:' + key,
+        name: null, displayName: saved.displayName, source: 'self_reported', nameVerified: false, verifiedAt: null };
+      row[kind === 'session' ? 'studentName' : kind === 'opportunity' ? 'uploadedBy' : 'ownerName'] = saved.displayName;
+    }
+    return result;
+  };
+}

+ 9 - 7
server/identity/manage.mjs

@@ -14,9 +14,9 @@ function privateJson(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, args[0] === 'deploy-employee-read' ? 'read-dist' : 'dist') }) {
+export async function manage({ args, request, invoke, releaseDir = path.join(root, args[0] === 'deploy-employee-read' ? 'read-dist' : args[0] === 'deploy-employee-name' ? 'name-dist' : 'dist') }) {
   const command = args[0], apply = args.includes('--apply');
-  const flags = { deploy: ['--apply', '--receipt-dir'], 'deploy-employee-read': ['--apply', '--receipt-dir'], configure: ['--apply', '--file', '--receipt-dir'], sync: ['--apply', '--sessions', '--request-id'], rollback: ['--apply', '--receipt'] }[command];
+  const flags = { deploy: ['--apply', '--receipt-dir'], 'deploy-employee-name': ['--apply', '--receipt-dir'], 'deploy-employee-read': ['--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'); } }
@@ -64,12 +64,13 @@ export async function manage({ args, request, invoke, releaseDir = path.join(roo
   if (command === 'rollback') {
     const receiptFile = value('--receipt'); if (!receiptFile) fail('USAGE');
     const receipt = privateJson(receiptFile);
+    const nameRelease = receipt.version === 'ksf.employee-name.20261103.v1';
     const readRelease = receipt.version === 'ksf.wecom-employee-read.20261103.v1';
-    if (!Array.isArray(receipt.entries) || (readRelease ? receipt.entries.length !== 1 || receipt.entries[0].name !== 'ksfReadWecomEmployee' : receipt.version !== 'ksf.wecom-identity.20261103.v1' || ![2, 4].includes(receipt.entries.length))) fail('INVALID_RECEIPT');
+    if (!Array.isArray(receipt.entries) || (nameRelease ? receipt.entries.length !== 2 || !receipt.entries.some(e => e.name === 'ksfGetPublicBoard') || !receipt.entries.some(e => e.name === 'ksfEmployeeDisplayName') : readRelease ? receipt.entries.length !== 1 || receipt.entries[0].name !== 'ksfReadWecomEmployee' : receipt.version !== 'ksf.wecom-identity.20261103.v1' || ![2, 4].includes(receipt.entries.length))) fail('INVALID_RECEIPT');
     const restore = [];
     if (new Set(receipt.entries.map((e) => e.name)).size !== receipt.entries.length) fail('INVALID_RECEIPT');
     for (const entry of receipt.entries) {
-      if (!(readRelease ? ['ksfReadWecomEmployee'] : ['ksfGetPublicBoard', 'ksfSyncWecomEmployee', 'ksfStartWorkshopSession', 'ksfSaveWorkshopTask']).includes(entry.name)) fail('INVALID_RECEIPT');
+      if (!(nameRelease ? ['ksfGetPublicBoard', 'ksfEmployeeDisplayName'] : readRelease ? ['ksfReadWecomEmployee'] : ['ksfGetPublicBoard', 'ksfSyncWecomEmployee', 'ksfStartWorkshopSession', 'ksfSaveWorkshopTask']).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);
@@ -86,10 +87,11 @@ export async function manage({ args, request, invoke, releaseDir = path.join(roo
     }
     return { ok: true, operation: 'rollback', applied: apply };
   }
-  if (!['deploy', 'deploy-employee-read'].includes(command)) fail('USAGE');
+  if (!['deploy', 'deploy-employee-read', 'deploy-employee-name'].includes(command)) fail('USAGE');
   const manifest = JSON.parse(fs.readFileSync(path.join(releaseDir, 'manifest.json'), 'utf8'));
-  const readRelease = command === 'deploy-employee-read';
-  if (manifest.version !== (readRelease ? 'ksf.wecom-employee-read.20261103.v1' : 'ksf.wecom-identity.20261103.v1')) fail('BAD_RELEASE');
+  const readRelease = command === 'deploy-employee-read', nameRelease = command === 'deploy-employee-name';
+  if (manifest.version !== (nameRelease ? 'ksf.employee-name.20261103.v1' : readRelease ? 'ksf.wecom-employee-read.20261103.v1' : 'ksf.wecom-identity.20261103.v1')) fail('BAD_RELEASE');
+  if (nameRelease && (manifest.functions?.length !== 2 || manifest.functions[0].name !== 'ksfGetPublicBoard' || !manifest.functions[0].priorSha256 || manifest.functions[1].name !== 'ksfEmployeeDisplayName' || manifest.functions[1].priorSha256 !== null)) fail('BAD_RELEASE');
   let sourceBase;
   if (readRelease) {
     if (manifest.functions?.length !== 1 || manifest.functions[0].name !== 'ksfReadWecomEmployee' || manifest.functions[0].priorSha256 !== null || manifest.sourceBase?.name !== 'ksfGetPublicBoard') fail('BAD_RELEASE');

+ 5 - 5
skills/ksf-workshop-runtime/FILES.sha256

@@ -1,10 +1,10 @@
+0957dbfbe9c060044a36717bdce64c4cc565073375162e011a9e55e79613a9d9  references/workshop-tasks.md
 197a02db39a78bd14803c27b455a51681b6ec32f8d50e69d6e32486a2e9fe2cc  references/write-tasks.md
-1e3176196a5582a50678db5eb55c20dad8b5f34e41ebeac9a6861d263ec950d8  scripts/ksf-workshop.mjs
 2605cbe8fd7be43b5969a4991f978dc0768dee5b4d65d18f7af15f75e9004a79  runtime-lock.json
-27a95c50a01d191368cebb6adf94f824eb81f2278ad79441d093f064d35ec61d  SKILL.md
-2fb2df62c72c948a19516b74213c050ab296aecc14018125958aa5a006395e22  references/identity.md
-4bc9869d0e35f550315869754fa11d64f8316115960df0411bd1eaebaba658bf  scripts/ksf-secure.mjs
+673bae6df90c015d0499fbc8fa77541c4d4e13a8ae93d04f67bfd096c384f32a  SKILL.md
 6ba3f0cb6527bc4a0a6218a898472ac65aa3dd9f3f57b597d77efcee641a7540  scripts/ksf-identity-bridge.mjs
 7ef128f7d022bfbc15c0ec813bfd87995c6297c6efe407a563c840f014aba477  scripts/ksf.mjs
-822b63029c65078cf42d21304aa704ad00fbcb56ca52754e66ce3c04013fc7be  references/workshop-tasks.md
 9224ef89a96425890d54961fda1f4fcae3403aa7c753fe1d70ac62082f2ff77e  scripts/ksf-identity.mjs
+e3013c0ff70a7d22890d98a6dcc4490422dc20ce5f899e95d0ca5dcc84fd0b23  scripts/ksf-secure.mjs
+e7a7e585296297f7777ba1da613b7139ba5ea489cec76f7e1aa8dcca2f199a11  references/identity.md
+eaf4ae5ca358b6b5a7f5df08ae825454df1dfaddf7c1f6979685257a0230eb68  scripts/ksf-workshop.mjs

+ 17 - 11
skills/ksf-workshop-runtime/SKILL.md

@@ -1,6 +1,6 @@
 ---
 name: ksf-workshop-runtime
-description: 康师傅面类产品创新工作坊。从企业微信数字人当前消息登记员工,上传时核对通讯录实名与成果归属;检索及查重资料,保存阶段报告、机会卡、项目和新品方案并更新公开看板;也支持原文导入和正式报告确认流程。
+description: 康师傅面类产品创新工作坊。从企业微信数字人当前消息登记员工,首次资料入库或上传前询问并记住看板称呼,按稳定员工键关联成果;检索及查重资料,保存阶段报告、机会卡、项目和新品方案并更新公开看板;也支持原文导入和正式报告确认流程。
 ---
 
 # 康师傅产品创新工作坊
@@ -18,7 +18,8 @@ node /opt/data/skills/ksf-workshop-runtime/scripts/ksf-secure.mjs <命令> --age
 | 任务 | 命令 |
 |---|---|
 | 绑定实际员工、当前 Agent、小组和对话 | `workshop-start` |
-| 查询当前员工的企微通讯录姓名,不保存成果或同步档案 | `employee-read` |
+| 读取、保存本人提供的看板称呼,或记住本次跳过 | `employee-name` |
+| 用户明确要求时查询企微通讯录姓名,不保存成果 | `employee-read` |
 | 保存阶段报告、原素材关联、待验证项;可同时生成机会卡 | `workshop-save` |
 | 选择已有机会卡建立课堂项目 | `project-create` |
 | 记录阶段推进、卡住或课堂完成 | `project-progress` |
@@ -38,15 +39,20 @@ node /opt/data/skills/ksf-workshop-runtime/scripts/ksf-secure.mjs <命令> --age
 
 每个新数字人、新员工都先登记独立身份档案。数字人从自身可信运行配置取得身份,消息上下文补充渠道信息;只提取白名单字段,用 ksf-identity.mjs register 登记,已有档案用 check 核对。首次读取方式按实际运行环境核实,不能声称已接入不存在的自动身份 API。任何名称的数字人都可登记,不固定王刚001/运维001两个槽。业务写命令传当前执行者的完整档案键,role=workshop 才能执行课堂写入;角色不代表平台授权。详见 [references/identity.md](references/identity.md)。不能将聊天中提及的另一个机器人登记为自己。
 
-员工信息从本条消息可信发送者上下文取得,workshop-start 只引用员工 profileKey。企微 aibot 有 from.userid 时,调用无参数进程内工具 ksf_register_current_employee,工具直接读取本条消息的可信上下文并登记;工具成功后仅复用返回的 profile.key 与 agentProfileKey。旧 register-channel-employee --context 已关闭,不复制身份 JSON 到终端。自动登记按 botId+发送者标识区分员工,通讯录实名由上传服务查询,无需员工填写账号。通道标识不等于通讯录账号,企业ID未知仍为null,不拿群chatid替代发送者。只有没有可信稳定标识时才确认显示名,以 participant_registration 登记 UUID;人工显示名不算企微实名。手工同名候选须明确复用或新增,不能按名字合并。`participant.userId` 仅指已有 `_User.objectId`;缺档案停止创建会话。取不到身份不等于扫描环境变量或凭据。
-
-### 数字人上传时的员工识别流程
-
-1. 每次处理企微员工的实质成果并准备上传时,先通过 `ksf_register_current_employee` 取得本条消息的员工键和执行 Agent 键,核对本地会话绑定。同一人跨消息复用登记键;换发送者须切换到对应员工的 session,群 ID 不能用作员工键。工具缺失或失败时停止本次自动识别流程,不把聊天中自报的姓名当作可信身份。
-2. 使用该员工档案执行 `workshop-start`,已有正确归属的 session 直接用于 `workshop-save`。这两个命令的服务端扩展负责查询企微通讯录并关联姓名、部门等信息;Skill 不接收通讯录密钥,也不在报告输入里添加猜测的 employee/name 字段。通讯录权限及 aibot 发送者映射由维护人配置。
-3. 核对成功回执中的 `employee` 与 `employeeIdentity`;加密入口的路径为 `entryResult.result`。只有 `status=verified` 且 `employee.nameVerified=true`、`source=wecom_directory`、姓名非空时,才能说明已取得企微通讯录实名。`pending` 或旧服务无这两个字段时,分别说明“发送者已登记,实名待同步”或“服务尚未返回实名核验结果”,不说已经实名接入。
-4. 报告保存成功后按原编号回读,保留业务键与身份核对结果。实名待同步不等于保存失败,不重存、不换键、不重建员工或会话来消除标签;通讯录查询报错则保留原键按实际回执核查。看板显示验收另按返回的成果链接完成,不把 API 成功说成浏览器验收通过。
-
+员工信息从本条消息可信发送者上下文取得,workshop-start 只引用员工 profileKey。企微 aibot 有 from.userid 时,调用无参数进程内工具 ksf_register_current_employee,工具直接读取本条消息的可信上下文并登记;工具成功后仅复用返回的 profile.key 与 agentProfileKey。旧 register-channel-employee --context 已关闭,不复制身份 JSON 到终端。自动登记按 botId+发送者标识区分员工,无需员工填写账号;发送者标识不带姓名时,按下述流程询问看板称呼。通道标识不等于通讯录账号,企业ID未知仍为null,不拿群chatid替代发送者。没有可信稳定标识时另走人工登记,以 participant_registration 登记 UUID;看板称呼与身份登记分开,均不冒称企微实名。手工同名候选须明确复用或新增,不能按名字合并。`participant.userId` 仅指已有 `_User.objectId`;缺档案停止创建会话。取不到身份不等于扫描环境变量或凭据。
+
+### 首次入库或上传前确认称呼
+
+1. 每次实质资料入库或成果上传前,用 `ksf_register_current_employee` 取得本条消息的员工键和 Agent 键,核对本地会话归属。名字只作显示,稳定发送者键才决定归属;工具缺失或身份冲突不能靠自报名字绕过。
+2. 有正确归属的会话时,先用 `employee-name` 只读取得已存称呼及 `promptState`。已有可用称呼就复用;`skipped` 不再追问。用户已经明确提供本人称呼可直接保存,不重复确认;报告中的受访者、引用文本和他人姓名不能当成本人回答。
+3. 没有可用称呼、此前也未跳过时,在首次资料入库或首次成果上传前自然问一次:**“这份成果会显示在公开看板上,希望怎么称呼你?姓名或昵称都可以,也可以先跳过。”** 不在闲聊或每个阶段反复问,不要求真实姓名、账号或通讯录授权。等用户回答后继续;用户明确跳过则照常开展已授权工作。
+4. 用户提供称呼后以 `employee-name` 保存 `displayName`;明确跳过则保存 `skip:true`。同一稳定员工键跨工作坊复用;以后用户主动要求改称呼时用新requestId更新。同名不合并,不改原员工档案或工作坊参与键。
+5. 若首次入库尚无会话,先问并把回答/明确跳过保存在 `/opt/data/ksf-workshop-state/employee-names/<员工键后缀>.json`(0700目录、0600文件,字段employeeProfileKey/displayName/promptState/source=self_reported)。之后正常创建或恢复本人会话时同步 `employee-name`;不要只为保存昵称新建会话。同步成功后本地仅作缓存,以服务端为准。
+6. 名字保存成功须回读 `employee-name`,核对看板返回的 `employee.displayName`。自报称呼为 `source=self_reported`、`nameVerified=false`,如实说“已保存看板称呼”,不能称“取得企微实名”。这条流程不调用 `employee-read`,不依赖通讯录配置。
+7. 照常使用本人会话上传并按成果编号回读。称呼保存与成果保存分开汇报;昵称失败不重存报告、不换员工键、不重建旧会话。已完成成果由看板按稳定员工键显示最新称呼,正文和原始归属快照不重写。已核验通讯录姓名优先显示;未核验称呼也能正常使用看板。
+
+命令输入及“跳过/改名”示例见 [references/workshop-tasks.md](references/workshop-tasks.md#看板称呼employee-name)。本地 `needsNameBinding` 仅反映不可变登记档案的占位名,不能用它覆盖已保存称呼或再次追问。
+
 用户只问“能否取得我的实名”时,在当前消息登记成功并核对已有会话后,直接执行 `employee-read` 做最小查询;输入和示例见 [references/workshop-tasks.md](references/workshop-tasks.md#员工实名最小查询employee-read)。它使用现有 `data:read` 权限,不需要 `identity:write` 或运维凭据;查询不更新看板,不新建会话或报告。无受控会话时先报告缺项,不为测试创建业务记录。
 
 具体回执、缺权限处理及本地待绑定标签与看板姓名的区别见 [references/identity.md](references/identity.md#上传时查询企业微信通讯录实名)。

+ 10 - 0
skills/ksf-workshop-runtime/references/identity.md

@@ -2,6 +2,14 @@
 
 每个新员工、每个新数字人先登记自己的档案,再创建工作坊会话。目录支持任意人数,不按姓名或两位现有机器人的角色分配固定槽。登记资料与平台认证分开:本流程不创建登录账号,`accountAuthenticated` 始终为 false。
 
+## 本人提供的看板称呼
+
+默认采用 SKILL.md 的“首次入库或上传前确认称呼”流程。发送者登记、显示称呼、通讯录实名是三个独立事实。没有目录权限时照常询问并保存昵称,不把通讯录查询作为业务前置条件。
+
+`employee-name` 只接受当前可信消息登记的员工profileKey和其已有会话;服务端校验服务owner、工作坊参与键派生关系及Agent。读取只用data:read;保存称呼或明确跳过使用既有ingest:write,无需identity:write或运维凭据。私有运行配置 `workshopDisplayNames` 按bot作用域+发送者摘要存储,跨工作坊复用,同名不合并。更新只涉及称呼注册表及操作审计,不改员工/Agent档案、会话索引、报告、项目或旧归属快照。
+
+`promptState=not_asked`且没有可用称呼才询问;`provided`直接复用,`skipped`继续业务不再追问。用户主动改名时可更新。自报姓名与昵称均标source=self_reported、nameVerified=false,不写入employee.name,也不冒称企微显示名或实名。看板在employee.displayName展示称呼,标“本人提供的称呼 · 未经通讯录核验”;已有可信通讯录姓名优先。历史本地占位名可保持原样,不代表保存昵称失败。
+
 ## 取得真实身份字段
 
 数字人优先读取当前执行实例的可信运行配置,渠道从当前消息上下文补充。先核实本环境实际提供的配置位置或接口,只提取下面的白名单,不扫描全盘凭据或输出配置原文。不能把聊天里提到的另一位机器人登记为自己,也不能声称已调用不存在的自动身份接口。
@@ -63,6 +71,8 @@ Agent 自身 account.accountId 必须是当前实例实际订阅 botId,channel
 
 ### 上传时查询企业微信通讯录实名
 
+本节为可选通讯录能力:只在维护人已启用通讯录配置时由上传服务查询;未配置则正常保存,返回pending,不阻塞前述自报称呼流程。不要为使用昵称主动运行employee-read或索要通讯录配置。
+
 Skill 负责顺序与结果核对:当前消息可信登记 → 核对员工/Agent 与 session 绑定 → start 或 save → 核对员工回执 → 成果回读。不要另造“让员工先登录网页/手填企微账号”的前置步骤。续传前仍调用当前消息登记工具;本地状态须同时绑定员工键和 Agent 键,不能因为同处一个群或沿用上一次 session 就跳过发送者核对。
 
 查询由 `ksfStartWorkshopSession` / `ksfSaveWorkshopTask` 服务端扩展执行。start 使用可信登记注入的账号,save 使用原 session 的私有归属快照。aibot 发送者须通过受控映射对应到通讯录 UserID,不能根据 wo/wr 前缀认定是通讯录账号。配置包含企微应用通讯录权限、企业范围和发送者映射,由维护人在私有运行配置中管理;Skill 不读取、不保存这些密钥,也不调用外部通讯录接口拼装姓名。

+ 18 - 0
skills/ksf-workshop-runtime/references/workshop-tasks.md

@@ -4,6 +4,24 @@
 
 业务写命令必须同时传 `--agent-profile <当前Agent完整档案键>`,读取已登记的自身身份。新员工、新数字人先按 identity.md 登记,任何名称都适用;仅 role=workshop 可课堂写入。只读命令可不传档案键。
 
+## 看板称呼:employee-name
+
+先从当前真实消息登记取得employeeProfileKey和Agent键,再核对已有会话。三种输入共用同一命令:
+
+```json
+{"sessionId":"已有本人会话编号","participant":{"profileKey":"当前员工完整档案键"}}
+```
+
+上例只读返回 `promptState=not_asked/provided/skipped`、`displayName`、`source`、`nameVerified`,`readOnly=true`且databaseWrites=0。只在未询问且无称呼时问一句;用户明确回答后,在同一输入加 `"displayName":"用户实际提供的姓名或昵称"`(1至40字),保存并供看板显示。用户明确说跳过时改为加 `"skip":true`,不带displayName。不把未回复自动当跳过。
+
+```sh
+node /opt/data/skills/ksf-workshop-runtime/scripts/ksf-secure.mjs employee-name --agent-profile <当前Agent完整键> --input /opt/data/私有目录/employee-name.json --request-id <本次操作稳定标记>
+```
+
+加密入口调用 `ksfEmployeeDisplayName`。读取需data:read;保存需额外ingest:write,两者均核对当前员工及Agent。读取、保存、回读使用不同requestId;相同保存请求幂等,改称呼使用新requestId。成功保存返回persisted=true、readOnly=false、businessRecordsChanged=0,随后省略displayName/skip回读确认。昵称通常source=self_reported、nameVerified=false;已有有效通讯录实名会优先返回,不将昵称替换实名。skip不会清除已有称呼。
+
+不改本地participant档案的name,不把昵称注入workshop-save/report/rawText,不重存旧报告。首次仅导入资料尚无session时,按SKILL.md临时记住本人回答,正常会话建立后同步;不为改名新建session。无目录配置也能使用本命令,不自动调用employee-read。
+
 ## 员工实名最小查询:employee-read
 
 本命令虽只读,仍必须传当前workshop角色的 `--agent-profile`,并先调用 `ksf_register_current_employee` 取得本条真实消息的员工profile.key。输入仅为已有会话和员工档案引用,不含姓名、账号或密钥:

+ 2 - 2
skills/ksf-workshop-runtime/scripts/ksf-secure.mjs

@@ -9,9 +9,9 @@ const self = fileURLToPath(import.meta.url);
 const sha256 = bytes => crypto.createHash('sha256').update(bytes).digest('hex');
 const ENTRY_HASH = '7ef128f7d022bfbc15c0ec813bfd87995c6297c6efe407a563c840f014aba477';
 const LOCK_HASH = '2605cbe8fd7be43b5969a4991f978dc0768dee5b4d65d18f7af15f75e9004a79';
-const WORKSHOP_ENTRY_HASH = '1e3176196a5582a50678db5eb55c20dad8b5f34e41ebeac9a6861d263ec950d8';
+const WORKSHOP_ENTRY_HASH = 'eaf4ae5ca358b6b5a7f5df08ae825454df1dfaddf7c1f6979685257a0230eb68';
 const IDENTITY_MODULE_HASH = '9224ef89a96425890d54961fda1f4fcae3403aa7c753fe1d70ac62082f2ff77e';
-const WORKSHOP_COMMANDS = ['workshop-start','workshop-save','project-create','project-progress','concept-save','project-status','workshop-import','workshop-dedup','workshop-search','workshop-read','workshop-contract','employee-read'];
+const WORKSHOP_COMMANDS = ['workshop-start','workshop-save','project-create','project-progress','concept-save','project-status','workshop-import','workshop-dedup','workshop-search','workshop-read','workshop-contract','employee-read','employee-name'];
 const fail = code => { throw Object.assign(new Error(code), { code }); };
 const requireThat = (value, code) => { if (!value) fail(code); };
 

+ 17 - 10
skills/ksf-workshop-runtime/scripts/ksf-workshop.mjs

@@ -6,7 +6,7 @@ import {readAgentProfileByKey,readParticipantProfile,bindOwnAgent,bindRegistered
 const self=fileURLToPath(import.meta.url);
 const commands={
  'workshop-start':'ksfStartWorkshopSession','workshop-save':'ksfSaveWorkshopTask',
- 'project-create':'ksfCreateProjectFromOpportunity','project-progress':'ksfTransitionProject','employee-read':'ksfReadWecomEmployee',
+ 'project-create':'ksfCreateProjectFromOpportunity','project-progress':'ksfTransitionProject','employee-read':'ksfReadWecomEmployee','employee-name':'ksfEmployeeDisplayName',
  'concept-save':'ksfSaveWorkshopConcept','project-status':'ksfGetWorkshopProject','workshop-search':'ksfSearchRecords',
  'workshop-import':null,'workshop-dedup':'ksfCheckDuplicates','workshop-read':'ksfGetRecord','workshop-contract':'ksfGetIngestContract'
 };
@@ -17,15 +17,22 @@ export async function runWorkshop(command,task,{requestId,call,agentProfile,part
  if(!Object.hasOwn(commands,command)||!task||typeof task!=='object'||Array.isArray(task))fail('INVALID_INPUT');
  if(typeof requestId!=='string'||!requestId||task.requestId&&task.requestId!==requestId)fail('BAD_REQUEST_ID');
  if(!['project-status','workshop-search','workshop-read','workshop-contract','workshop-dedup'].includes(command)&&!agentProfile)fail('AGENT_PROFILE_REQUIRED');
- if(['workshop-start','employee-read'].includes(command)&&!participantProfile)fail('PARTICIPANT_PROFILE_REQUIRED');
- if(command==='employee-read'){
-  if(Object.keys(task).some(k=>!['sessionId','participant','requestId'].includes(k))||typeof task.sessionId!=='string'||!task.sessionId)fail('INVALID_INPUT');
+ if(['workshop-start','employee-read','employee-name'].includes(command)&&!participantProfile)fail('PARTICIPANT_PROFILE_REQUIRED');
+ if(['employee-read','employee-name'].includes(command)){
+  const naming=command==='employee-name',hasName=Object.hasOwn(task,'displayName'),hasSkip=Object.hasOwn(task,'skip'),writing=hasName||hasSkip;
+  if(Object.keys(task).some(k=>!['sessionId','participant','requestId',...(naming?['displayName','skip']:[])].includes(k))||typeof task.sessionId!=='string'||!task.sessionId)fail('INVALID_INPUT');
+  if(naming&&(hasName&&hasSkip||hasName&&(typeof task.displayName!=='string'||!task.displayName.trim()||[...task.displayName.trim()].length>40||/[\p{Cc}\p{Cf}]/u.test(task.displayName))||hasSkip&&task.skip!==true))fail('INVALID_INPUT');
   if(agentProfile.role!=='workshop')fail('AGENT_ROLE_MISMATCH');
   bindRegisteredParticipant(task.participant,participantProfile);
   const current=await call('ksfGetRecord',{className:'KfSession',objectId:task.sessionId});
   assertOwnSession(current.record,agentProfile);
   if(current.record?.meta?.attribution?.participantKey!=='participant:'+sha(canonical([current.record.workshopKey,participantProfile.key])))fail('PARTICIPANT_SESSION_MISMATCH');
-  const result=await call(commands[command],{sessionId:task.sessionId,participantProfileKey:participantProfile.key,agentProfileKey:agentProfile.key,requestId});
+  const result=await call(commands[command],{sessionId:task.sessionId,participantProfileKey:participantProfile.key,agentProfileKey:agentProfile.key,requestId,...(naming&&hasName?{displayName:task.displayName}:{}),...(naming&&hasSkip?{skip:true}:{})});
+  if(naming){
+   if(result?.sessionId!==task.sessionId||result.readOnly!==!writing||result.persisted!==writing||!['not_asked','provided','skipped'].includes(result.promptState)||result.businessRecordsChanged!==0||!writing&&result.databaseWrites!==0||writing&&hasName&&result.source!=='wecom_directory'&&result.displayName!==task.displayName.trim().normalize('NFC'))fail('EMPLOYEE_NAME_RECEIPT_MISMATCH');
+   if(typeof result.nameVerified!=='boolean'||result.nameVerified&&result.source!=='wecom_directory'||!result.nameVerified&&![null,'self_reported'].includes(result.source)||result.promptState==='provided'&&(typeof result.displayName!=='string'||!result.displayName.trim())||result.promptState!=='provided'&&result.displayName!==null)fail('EMPLOYEE_NAME_RECEIPT_MISMATCH');
+   return redactIdentity(result);
+  }
   if(result?.readOnly!==true||result.databaseWrites!==0||result.sessionId!==task.sessionId||result.employeeIdentity?.status!=='verified'||result.employeeIdentity.persisted!==false||result.employee?.nameVerified!==true||result.employee.source!=='wecom_directory'||typeof result.employee.name!=='string'||!result.employee.name.trim())fail('EMPLOYEE_READ_RECEIPT_MISMATCH');
   return redactIdentity(result);
  }
@@ -102,13 +109,13 @@ if(process.argv[1]&&path.resolve(process.argv[1])===self){
   const [command,...args]=process.argv.slice(2);const flags={};for(let i=0;i<args.length;i+=2){if(!['--input','--request-id','--agent-profile'].includes(args[i])||!args[i+1]||flags[args[i]])fail('USAGE');flags[args[i]]=args[i+1];}
   if(!flags['--input']||!path.isAbsolute(flags['--input']))fail('INPUT_PATH_MUST_BE_ABSOLUTE');
   const task=JSON.parse(fs.readFileSync(flags['--input'],'utf8'));
-  const readOnly=['project-status','workshop-search','workshop-read','workshop-contract','workshop-dedup','employee-read'].includes(command);
+  const readOnly=['project-status','workshop-search','workshop-read','workshop-contract','workshop-dedup','employee-read'].includes(command)||command==='employee-name'&&!Object.hasOwn(task,'displayName')&&!Object.hasOwn(task,'skip');
   if(flags['--agent-profile']&&!/^agent-(?:registration:[a-f0-9-]{36}|account:[a-f0-9]{64})$/.test(flags['--agent-profile']))fail('INVALID_AGENT_KEY');
-  if((!readOnly||command==='employee-read')&&!flags['--agent-profile'])fail('AGENT_PROFILE_REQUIRED');
+  if((!readOnly||['employee-read','employee-name'].includes(command))&&!flags['--agent-profile'])fail('AGENT_PROFILE_REQUIRED');
   const agentProfile=flags['--agent-profile']?readAgentProfileByKey(flags['--agent-profile']):null;
-  if(['workshop-start','employee-read'].includes(command)&&!task.participant?.profileKey)fail('PARTICIPANT_PROFILE_REQUIRED');
-  const participantProfile=['workshop-start','employee-read'].includes(command)?readParticipantProfile(task.participant?.profileKey):null;
-  if(['workshop-start','employee-read'].includes(command)&&!participantProfile)fail('PARTICIPANT_PROFILE_REQUIRED');
+  if(['workshop-start','employee-read','employee-name'].includes(command)&&!task.participant?.profileKey)fail('PARTICIPANT_PROFILE_REQUIRED');
+  const participantProfile=['workshop-start','employee-read','employee-name'].includes(command)?readParticipantProfile(task.participant?.profileKey):null;
+  if(['workshop-start','employee-read','employee-name'].includes(command)&&!participantProfile)fail('PARTICIPANT_PROFILE_REQUIRED');
   const result=await runWorkshop(command,task,{requestId:flags['--request-id'],agentProfile,participantProfile,call:createWorkshopCaller({token:process.env.KSF_SERVICE_TOKEN})});
   const out=JSON.stringify({...base,command,requestId:flags['--request-id'],ok:true,readOnly,...(agentProfile?{executingAgent:{key:agentProfile.key,name:agentProfile.name,identityBasis:agentProfile.identityBasis}}:{}),result},null,2);
   if(process.env.KSF_SERVICE_TOKEN&&out.includes(process.env.KSF_SERVICE_TOKEN))fail('OUTPUT_CONTAINS_SECRET');console.log(out);

+ 1 - 1
src/core/kfs/employee.js

@@ -46,6 +46,6 @@ 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 ? '企业微信显示名 · 实名待同步' : '企业微信发送者 · 姓名待同步');
+  parts.push(employee.nameVerified ? '企业微信通讯录姓名' : employee.source === 'self_reported' && employee.displayName ? '本人提供的称呼 · 未经通讯录核验' : employee.displayName ? '企业微信显示名 · 实名待同步' : '企业微信发送者 · 姓名待同步');
   return [...new Set(parts)].join(' · ');
 }

+ 151 - 0
test/employee-name.test.js

@@ -0,0 +1,151 @@
+import test from 'node:test';
+import assert from 'node:assert/strict';
+import crypto from 'node:crypto';
+import fs from 'node:fs';
+import os from 'node:os';
+import path from 'node:path';
+import { attachEmployeeNameHandlers } from '../server/identity/employee-name.mjs';
+import { attachWecomEmployeeHandlers } from '../server/identity/employee-handlers.mjs';
+import { buildEmployeeName } from '../server/identity/build-employee-name.mjs';
+import { manage } from '../server/identity/manage.mjs';
+import { runWorkshop } from '../skills/ksf-workshop-runtime/scripts/ksf-workshop.mjs';
+import { parseArgs } from '../skills/ksf-workshop-runtime/scripts/ksf-secure.mjs';
+import { employeeCaption } from '../src/core/kfs/employee.js';
+import { buildModel } from '../src/core/kfs/build.js';
+import { publishEmployeeName } from '../scripts/publish-employee-name.mjs';
+
+const hash = v => crypto.createHash('sha256').update(v).digest('hex');
+const canonical = v => v && typeof v === 'object' ? Array.isArray(v) ? '[' + v.map(canonical).join(',') + ']' : '{' + Object.keys(v).sort().map(k => JSON.stringify(k) + ':' + canonical(v[k])).join(',') + '}' : JSON.stringify(v);
+const account = { platform: 'wecom', identifierType: 'wecom_aibot_sender', scopeId: 'test-bot', accountId: 'test-sender' };
+const participant = { key: 'participant-account:' + 'a'.repeat(64), name: '员工待绑定', identityBasis: 'channel_context', account };
+const agent = { key: 'agent-account:' + 'b'.repeat(64), name: '测试数字人', role: 'workshop', runtimePlatform: 'hermes', channel: 'wecom', account: { platform: 'wecom', accountId: account.scopeId } };
+const params = { sessionId: 'session1', participantProfileKey: participant.key, agentProfileKey: agent.key, requestId: 'name1' };
+const keyOf = a => hash(canonical(['wecom_aibot_sender', a.scopeId, a.accountId]));
+const blank = () => Object.fromEntries(['session', 'artifact', 'project', 'opportunity', 'insight', 'source'].map(k => [k, []]));
+function fixture() {
+  const sessions = ['session1', 'session2'].map((id, i) => ({ objectId: id, studentName: '历史占位名', workshopKey: 'w' + i, meta: { ownerId: 'service1', attribution: {
+    participantKey: 'participant:' + hash(canonical(['w' + i, participant.key])), participantAccount: account, identityBasis: 'channel_context',
+    agentKey: agent.key, agentName: agent.name, agentRuntimePlatform: agent.runtimePlatform, agentChannel: agent.channel, agentAccount: agent.account,
+  } } }));
+  const raw = { ...blank(), session: sessions.map(s => ({ objectId: s.objectId, studentName: s.studentName })),
+    artifact: [{ objectId: 'artifact1', ownerSession: 'session1', ownerName: '历史占位名', rawText: '已完成报告不重写' }],
+    project: [{ objectId: 'project1', ownerSession: 'session2', ownerName: '历史占位名' }] };
+  const writes = [], settings = { unrelated: 'preserved' }, runtime = { objectId: 'runtime1', key: 'runtime', value: structuredClone(settings) }, operations = new Map();
+  const store = { get: async (kind, id) => structuredClone(sessions.find(s => s.objectId === id)), lock: async () => {}, list: async () => [structuredClone(runtime)],
+    update: async (kind, id, patch) => { writes.push({ kind, id, patch }); Object.assign(runtime, patch); } };
+  const fail = code => { throw Object.assign(Error(code), { code }); };
+  const app = { handlers: { ksfGetPublicBoard: async () => structuredClone(raw) }, hash, canonical, fail,
+    need: (actor, scope) => { if (!actor) fail('AUTH_REQUIRED'); if (!actor.scopes.some(s => s === scope || s === '*')) fail('FORBIDDEN'); },
+    operation: async (actor, name, p, fn) => {
+      const key = actor.id + name + p.requestId, prior = operations.get(key);
+      if (prior) { if (prior.hash !== hash(canonical(p))) fail('IDEMPOTENCY_CONFLICT'); return prior.result; }
+      const result = await fn(store); operations.set(key, { hash: hash(canonical(p)), result }); return result;
+    } };
+  attachWecomEmployeeHandlers(app, { store, settings, fetchImpl: () => { throw Error('directory must not be called'); } });
+  attachEmployeeNameHandlers(app, { store, settings });
+  return { app, sessions, raw, runtime, settings, writes, operations, actor: { id: 'service1', scopes: ['data:read', 'ingest:write'] } };
+}
+
+test('昵称无需通讯录:Skill读取、保存、回读,跨工作坊显示且历史成果不变', async () => {
+  const f = fixture(), before = structuredClone({ sessions: f.sessions, raw: f.raw }), task = { sessionId: 'session1', participant: { profileKey: participant.key } };
+  const run = (input, requestId) => runWorkshop('employee-name', input, { agentProfile: agent, participantProfile: participant, requestId,
+    call: (name, p) => name === 'ksfGetRecord' ? Promise.resolve({ record: structuredClone(f.sessions[0]) }) : f.app.handlers[name](p, f.actor) });
+  assert.equal((await run(task, 'read1')).promptState, 'not_asked'); assert.equal(f.writes.length, 0);
+  const saved = await run({ ...task, displayName: '测试昵称甲' }, 'save1');
+  assert.equal(saved.displayName, '测试昵称甲'); assert.equal(saved.source, 'self_reported'); assert.equal(saved.nameVerified, false);
+  assert.equal(saved.persisted, true); assert.equal(saved.businessRecordsChanged, 0);
+  assert.equal((await run(task, 'read2')).displayName, '测试昵称甲');
+  assert.equal((await f.app.handlers.ksfEmployeeDisplayName({ ...params, sessionId: 'session2' }, f.actor)).displayName, '测试昵称甲');
+  const result = await f.app.handlers.ksfGetPublicBoard({}), db = buildModel(result);
+  assert.equal(db.members.length, 1); assert.equal(db.members[0].name, '测试昵称甲');
+  assert.equal(result.artifact[0].ownerName, '测试昵称甲'); assert.equal(result.project[0].ownerName, '测试昵称甲');
+  assert.equal(result.session[0].employee.name, null); assert.equal(result.session[0].employee.nameVerified, false);
+  assert.match(employeeCaption(result.session[0].employee), /本人提供的称呼/);
+  assert.deepEqual({ sessions: f.sessions, raw: f.raw }, before); assert.equal(f.runtime.value.unrelated, 'preserved');
+  assert.deepEqual(f.writes.map(w => w.kind), ['KsfRuntimeConfig']);
+  assert.ok(!JSON.stringify(result).includes(account.accountId));
+});
+
+test('明确跳过会记住;同请求幂等、改名需新键、同名不合并', async () => {
+  const f = fixture(), call = p => f.app.handlers.ksfEmployeeDisplayName(p, f.actor);
+  await call({ ...params, skip: true }); assert.equal((await call(params)).promptState, 'skipped');
+  await call({ ...params, skip: true }); assert.equal(f.writes.length, 1);
+  await assert.rejects(() => call({ ...params, displayName: '另一个称呼' }), { code: 'IDEMPOTENCY_CONFLICT' });
+  await call({ ...params, requestId: 'name2', displayName: '共同昵称' });
+  await call({ ...params, requestId: 'name3', skip: true }); assert.equal((await call(params)).displayName, '共同昵称');
+  const second = structuredClone(f.sessions[0]); second.objectId = 'session3'; second.meta.attribution.participantAccount.accountId = 'another-sender'; f.sessions.push(second);
+  f.raw.session.push({ objectId: 'session3', studentName: '历史名' });
+  await call({ ...params, sessionId: 'session3', requestId: 'name4', displayName: '共同昵称' });
+  const db = buildModel(await f.app.handlers.ksfGetPublicBoard({})); assert.equal(db.members.length, 2);
+  assert.notEqual(db.members[0].employee.key, db.members[1].employee.key);
+});
+
+test('已核验通讯录姓名优先,昵称不被当作企微实名或企微显示名', async () => {
+  const f = fixture(); await f.app.handlers.ksfEmployeeDisplayName({ ...params, displayName: '自报称呼' }, f.actor);
+  f.settings.wecomDirectory = { enabled: true, corpId: 'test-corp', secret: 'test-secret', bots: { 'test-bot': { mappingVerified: true, senderIdType: 'mapped', userIds: { 'test-sender': 'test-user' } } } };
+  f.settings.wecomEmployees = { [keyOf(account)]: { version: 'ksf.wecom-employee.v1', key: 'employee:' + hash(canonical(['test-corp', 'test-user'])), source: 'wecom_directory', nameVerified: true, name: '目录姓名', verifiedAt: new Date().toISOString() } };
+  // Reconstruct like a new server request, which loads fresh runtime settings.
+  attachWecomEmployeeHandlers(f.app, { store: { get: async (_, id) => f.sessions.find(s => s.objectId === id) }, settings: f.settings });
+  attachEmployeeNameHandlers(f.app, { store: { get: async (_, id) => f.sessions.find(s => s.objectId === id) }, settings: f.settings });
+  assert.equal((await f.app.handlers.ksfEmployeeDisplayName(params, f.actor)).displayName, '目录姓名');
+  const board = await f.app.handlers.ksfGetPublicBoard({}); assert.equal(board.session[0].studentName, '目录姓名');
+});
+
+test('匿名、跨员工/Agent/服务、写权限不足及非法输入在保存前被拒绝', async () => {
+  for (const [p, actor, code] of [
+    [params, null, 'AUTH_REQUIRED'], [{ ...params, displayName: '昵称' }, { id: 'service1', scopes: ['data:read'] }, 'FORBIDDEN'],
+    [params, { id: 'other', scopes: ['data:read'] }, 'FORBIDDEN'],
+    [{ ...params, participantProfileKey: 'participant-account:' + 'c'.repeat(64) }, undefined, 'IDENTITY_CONFLICT'],
+    [{ ...params, agentProfileKey: 'agent-account:' + 'c'.repeat(64) }, undefined, 'IDENTITY_CONFLICT'],
+    [{ ...params, displayName: '名字', skip: true }, undefined, 'VALIDATION_FAILED'],
+    [{ ...params, displayName: '\n伪造多行' }, undefined, 'VALIDATION_FAILED'],
+    [{ ...params, displayName: '太'.repeat(41) }, undefined, 'VALIDATION_FAILED'],
+    [{ ...params, skip: false }, undefined, 'VALIDATION_FAILED'],
+  ]) {
+    const f = fixture(); await assert.rejects(() => f.app.handlers.ksfEmployeeDisplayName(p, actor === undefined ? f.actor : actor), { code }); assert.equal(f.writes.length, 0);
+  }
+  assert.equal(parseArgs(['employee-name', '--input', path.resolve('input.json'), '--request-id', 'test1', '--agent-profile', agent.key]).command, 'employee-name');
+});
+
+test('两函数增量部署可幂等、回退,保留其他四函数并拒绝并发覆盖', async t => {
+  const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'ksf-name-deploy-')); t.after(() => fs.rmSync(dir, { recursive: true }));
+  const base = { name: 'ksfGetPublicBoard', objectId: 'public1', ACL: {}, code: 'function bootstrap(){attachWecomEmployeeHandlers(app,{store,settings});}\nfunction handler(request,response){return runDatabaseFunction("ksfGetPublicBoard",request,response,{});}' };
+  const built = buildEmployeeName(base);
+  built.functions.forEach(f => fs.writeFileSync(path.join(dir, f.name + '.js'), f.code));
+  fs.writeFileSync(path.join(dir, 'manifest.json'), JSON.stringify({ ...built, functions: built.functions.map(({ code, ...m }) => m) }));
+  const rows = [structuredClone(base), ...['ksfReadWecomEmployee', 'ksfSyncWecomEmployee', 'ksfStartWorkshopSession', 'ksfSaveWorkshopTask'].map(name => ({ name, objectId: name, code: 'preserve', ACL: {} }))], before = structuredClone(rows), writes = [];
+  const request = async (method, route, body) => {
+    if (route.includes('?')) return { results: structuredClone(rows.filter(r => r.name === JSON.parse(new URL('https://test' + route).searchParams.get('where')).name)) };
+    const id = route.split('/').at(-1);
+    if (method === 'GET') return structuredClone(rows.find(r => r.objectId === id));
+    writes.push({ method, id });
+    if (method === 'POST') { rows.push({ objectId: 'nameFn', ...body }); return { objectId: 'nameFn' }; }
+    if (method === 'PUT') { Object.assign(rows.find(r => r.objectId === id), body); return {}; }
+    if (method === 'DELETE') { rows.splice(rows.findIndex(r => r.objectId === id), 1); return {}; }
+  };
+  const run = args => manage({ args, request, releaseDir: dir });
+  assert.equal((await run(['deploy-employee-name'])).readOnly, true); assert.equal(writes.length, 0);
+  const saved = await run(['deploy-employee-name', '--apply', '--receipt-dir', dir]); assert.equal(saved.functions, 2);
+  assert.deepEqual(rows.slice(1, 5), before.slice(1)); assert.equal((await run(['deploy-employee-name', '--apply', '--receipt-dir', dir])).unchanged, true);
+  await run(['rollback', '--receipt', saved.receipt, '--apply']); assert.deepEqual(rows, before);
+  rows[0].code += '// concurrent'; await assert.rejects(() => run(['deploy-employee-name']), { code: 'FUNCTION_BASELINE_MISMATCH' });
+});
+
+test('一文件静态补丁先预检、可回退、未知改动不覆盖', async t => {
+  const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'ksf-name-static-')); t.after(() => fs.rmSync(dir, { recursive: true }));
+  const sourceDir = path.join(dir, 'package'), targetDir = path.join(dir, 'site'), file = 'src/core/kfs/employee.js';
+  // This is the exact deployed r5 file; derive its old label from the maintained source.
+  const next = fs.readFileSync(new URL('../src/core/kfs/employee.js', import.meta.url), 'utf8').replaceAll('\r\n', '\n');
+  const prior = next.replace("employee.source === 'self_reported' && employee.displayName ? '本人提供的称呼 · 未经通讯录核验' : ", '');
+  assert.equal(hash(prior), 'd2b312ac7ae6c749279a9c04c9e87e697b86c269e503ab3009c6e1b69c852eb1');
+  for (const root of [path.join(sourceDir, 'payload'), targetDir]) fs.mkdirSync(path.join(root, 'src/core/kfs'), { recursive: true });
+  fs.mkdirSync(path.join(sourceDir, 'deploy')); fs.writeFileSync(path.join(targetDir, 'index.html'), 'preserved-home');
+  fs.writeFileSync(path.join(targetDir, file), prior); fs.writeFileSync(path.join(sourceDir, 'payload', file), next);
+  fs.writeFileSync(path.join(sourceDir, 'deploy/employee-name-static.json'), JSON.stringify({ release: 'ksf.employee-name.20261103.v1', path: file, prior: hash(prior), sha256: hash(next) }));
+  const run = extra => publishEmployeeName({ sourceDir, targetDir, ...extra });
+  assert.equal(run().readOnly, true); assert.equal(fs.readFileSync(path.join(targetDir, file), 'utf8'), prior);
+  const applied = run({ apply: true }); assert.equal(applied.filesystemVerified, true); assert.equal(run({ apply: true }).changes.length, 0);
+  run({ backupFile: applied.backup, apply: true }); assert.equal(fs.readFileSync(path.join(targetDir, file), 'utf8'), prior);
+  fs.appendFileSync(path.join(targetDir, file), '// maintainer'); assert.throws(() => run({ apply: true }), { code: 'UNKNOWN_TARGET_CONTENT' });
+  assert.equal(fs.readFileSync(path.join(targetDir, 'index.html'), 'utf8'), 'preserved-home');
+});