Эх сурвалжийг харах

feat: resolve WeCom employee identity automatically on digital-human uploads

pengzhikang 1 өдөр өмнө
parent
commit
3e9c57bbee

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

@@ -1,6 +1,6 @@
 {
 {
   "version": "ksf.five-views.publish.v1",
   "version": "ksf.five-views.publish.v1",
-  "release": "ksf.workshop-board.20261103.v4",
+  "release": "ksf.workshop-board.20261103.v5",
   "homepageSha256": "dfeb6a6d0b63e8e1190f9308bf6619a5ca523a5a2f91af5f7ca5ad5fc9642db3",
   "homepageSha256": "dfeb6a6d0b63e8e1190f9308bf6619a5ca523a5a2f91af5f7ca5ad5fc9642db3",
   "files": [
   "files": [
     {
     {

+ 3 - 3
docs/showcase/deploy.md

@@ -1,12 +1,12 @@
 # 运维001:产品创新看板与系统介绍部署
 # 运维001:产品创新看板与系统介绍部署
 
 
-本包v4包含企业微信姓名展示、成员归一、中文裁切修复及导航纠正,替代此前r1、r2、r3包,勿先后叠加发布。本包尚未部署。本节只更新34个静态文件;企业微信后端和首次同步按同包`docs/wecom-identity-integration-20261103.md`执行。静态发布不改数据库、首页、nginx、Schema、ACL、Skill或wrapper,不需要OBS凭据或重启。
+本包v5包含数字人上传时自动关联企业微信姓名、成员归一、中文裁切修复及导航纠正,替代此前r1至r4包,勿先后叠加发布。本包尚未部署。本节只更新34个静态文件;企业微信后端和首次同步按同包`docs/wecom-identity-integration-20261103.md`执行。静态发布不改数据库、首页、nginx、Schema、ACL、Skill或wrapper,不需要OBS凭据或重启。
 
 
 ## 包与目录
 ## 包与目录
 
 
-发布版本:`ksf.workshop-board.20261103.v4`。脚本需要Node.js 20或以上,无npm安装步骤。
+发布版本:`ksf.workshop-board.20261103.v5`。脚本需要Node.js 20或以上,无npm安装步骤。
 
 
-将zip解压到站点以外的新目录,例如`/opt/ksf-workshop-board-20261103-r4/`。不要解压到站点内。进入解包根目录,执行`sha256sum -c FILES.sha256`,必须全部通过。
+将zip解压到站点以外的新目录,例如`/opt/ksf-workshop-board-20261103-r5/`。不要解压到站点内。进入解包根目录,执行`sha256sum -c FILES.sha256`,必须全部通过。
 
 
 此前维护人核实的站点为`/opt/kangshifu-web`,执行前再次从生效的nginx配置确认真实root。若不同,向脚本传入实际绝对路径,不猜目录。
 此前维护人核实的站点为`/opt/kangshifu-web`,执行前再次从生效的nginx配置确认真实root。若不同,向脚本传入实际绝对路径,不猜目录。
 
 

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

@@ -1,8 +1,8 @@
 {
 {
-  "version": "ksf.workshop-board.validation.20261103.v4",
+  "version": "ksf.workshop-board.validation.20261103.v5",
   "nodeTests": {
   "nodeTests": {
-    "total": 80,
-    "passed": 80,
+    "total": 84,
+    "passed": 84,
     "failed": 0
     "failed": 0
   },
   },
   "staticChecks": {
   "staticChecks": {
@@ -33,6 +33,8 @@
   "scope": {
   "scope": {
     "baseline": 334,
     "baseline": 334,
     "extension": 16446,
     "extension": 16446,
-    "deployed": false
+    "deployed": false,
+    "automaticEmployeeUploadHooks": true,
+    "productionEmployeeUploadVerified": false
   }
   }
 }
 }

+ 11 - 7
docs/wecom-identity-integration-20261103.md

@@ -1,6 +1,8 @@
-# 运维001:企业微信姓名接入与成员归一
+# 运维001:数字人上传自动关联企业微信姓名(r5)
 
 
-用户已要求接入并显示企业微信真实姓名和可识别信息。代码、测试及发布准备已完成;本机没有通讯录应用凭据、已核实的发送者映射或远程终端,尚未实际取得姓名或部署。由运维001按现有职责发布,不把本机夹具验收写成生产实名接通。
+用户要求员工在企业微信中通过数字人生成、上传成果时,自动取得并关联员工身份,无需员工在看板登录、填姓名或再次绑定。r5已把姓名查询接到开始工作坊和上传报告两个入口。代码、测试及发布准备已完成;本机没有通讯录应用凭据、已核实的发送者映射或远程终端,尚未实际取得姓名或部署。由运维001按现有职责发布,不把本机夹具验收写成生产实名接通。
+
+工作链为:本条企微消息的可信botId+from.userid → 数字人现有无参数登记工具 → 当前员工的工作坊session → 开始/上传时服务端读取账号并查通讯录 → 自动同步身份 → 原报告写入 → 公开看板显示姓名及归属。模型正文中的姓名、其他人的消息或群chatId不用于认领成果。企业管理员/运维一次性接通应用权限及ID映射,员工不逐份操作。
 
 
 ## 已知现状与显示行为
 ## 已知现状与显示行为
 
 
@@ -33,17 +35,19 @@ node server/identity/manage.mjs sync --sessions 2InAdyzimm,btrFqkz0yw --apply --
 
 
 每步失败即停,保留stdout/stderr/exitcode和私有回执;未知写结果先核对,不自动换键。dry同步会调用企微读取接口,零数据库写入;apply只更新KsfRuntimeConfig.value.wecomEmployees及一条成功操作审计,不重存业务报告或改工作坊进度。配置修改先留私有备份,再合并当前运行配置;不覆盖signingSecret和其他设置。
 每步失败即停,保留stdout/stderr/exitcode和私有回执;未知写结果先核对,不自动换键。dry同步会调用企微读取接口,零数据库写入;apply只更新KsfRuntimeConfig.value.wecomEmployees及一条成功操作审计,不重存业务报告或改工作坊进度。配置修改先留私有备份,再合并当前运行配置;不覆盖signingSecret和其他设置。
 
 
-发布仅更新`ksfGetPublicBoard`(对象inPfC51rvn)并新增`ksfSyncWecomEmployee`。基于现网Function.code原样叠加本次身份处理,不把本地旧版整套后端覆盖上去。原公开函数基线SHA256须为`266a84fc00de4634f0efb59ddd7a7aaa4e86f24ee310831ea731ea7c133040c9`;未知版本停止。未改StartSession、Skill、wrapper、Schema或ACL。
+发布更新`ksfGetPublicBoard`(inPfC51rvn)、`ksfStartWorkshopSession`(yKpUay6RDG)、`ksfSaveWorkshopTask`(f0faHuk5Br),新增`ksfSyncWecomEmployee`。四函数均从各自现网Function.code叠加身份处理,不把本地旧版整套后端覆盖上去。各旧函数基线见包内`server/identity/dist/manifest.json`;未知版本停止。未改Skill、wrapper、Schema或ACL。
+
+开始/上传只需原本的ingest:write权限:先核对服务身份、会话归属和数字人作用域,再由服务端从当前会话私有快照查姓名,并保存身份映射和审计,最后执行原业务保存。员工、模型都不传姓名作为查询结果。已启用通讯录时,错映射、无权限或查询失败均在报告写入前停止;身份查询失败不会被误写成“报告已保存”。未启用通讯录时沿用稳定发送者归属,并在回执中标`employeeIdentity.status=pending`,不能称实名已取得。原业务回执的报告ID/幂等键保留,增加`employee`及自动同步状态。
 
 
 同步函数须具备identity:write或管理权限。它只接受会话编号,由服务端读取私有账号并查询企微;不接收调用者填写的姓名、部门或UserID。公开查询只读已同步档案,不公开调用企微接口。
 同步函数须具备identity:write或管理权限。它只接受会话编号,由服务端读取私有账号并查询企微;不接收调用者填写的姓名、部门或UserID。公开查询只读已同步档案,不公开调用企微接口。
 
 
 ## 持续同步和浏览器验收
 ## 持续同步和浏览器验收
 
 
-首次人工核对通过后,把`node /实际包目录/server/identity/scheduled-sync.mjs`接到维护人现有的每10分钟计划任务,继承受限KSF_PLATFORM_CONFIG。脚本发现已有公开企微会话,按分钟业务键执行一次同步;错误不自动重试。后续新会话在下一次成功同步后显示通讯录姓名,浏览器原轮询会刷新。若要求新消息即时同步,另接适配器注册成功钩子;本包不宣称实现即时推送。
+r5开始/上传入口已自动同步,员工上传成果不依赖定时任务。另可将`node /实际包目录/server/identity/scheduled-sync.mjs`接到维护人现有的每10分钟计划任务,继承受限KSF_PLATFORM_CONFIG,仅用于历史补齐和部门/职务变化刷新。脚本按分钟业务键执行一次;错误不自动重试。业务会话创建后已有姓名,报告上传回执同步返回姓名,看板原轮询刷新展示。
 
 
-前端使用同包`docs/showcase/deploy.md`发布34个静态文件。包替代此前尚未发布的r1/r2/r3,不叠加旧包。检查成员页两个原测试会话归在同一个成员名下,9+6=15份成果、两个项目保留;成员的姓名与通讯录只读结果一致,部门/职务以实际返回为准。核对机会卡和报告上传人同名、原报告正文/ID/数量不变。
+前端使用同包`docs/showcase/deploy.md`发布34个静态文件。包替代此前尚未发布的r1至r4,不叠加旧包。检查成员页两个原测试会话归在同一个成员名下,9+6=15份成果、两个项目保留;成员的姓名与通讯录只读结果一致,部门/职务以实际返回为准。核对机会卡和报告上传人同名、原报告正文/ID/数量不变。
 
 
-生产验收需记录目录查询成功、同步审计、公开响应不含账号/Secret、桌面及手机显示、同名不同人、另一个发送者不会串身份。没有生产浏览器验收前`browserVerified=false`。
+生产验收需用不同员工的真实新消息各走一次数字人登记→开始/上传,核对回执`employeeIdentity.automatic=true/status=verified`、姓名与当前发送者一致,群内两人不串身份;无需员工到网页选择“我是谁”。记录目录查询成功、同步审计、公开响应不含账号/Secret、桌面及手机显示、同名不同人。没有生产浏览器验收前`browserVerified=false`。
 
 
 ## 回退
 ## 回退
 
 
@@ -54,7 +58,7 @@ node server/identity/manage.mjs rollback --receipt /实际私有目录/identity-
 node server/identity/manage.mjs rollback --receipt /实际私有目录/identity-deploy-实际编号.json --apply
 node server/identity/manage.mjs rollback --receipt /实际私有目录/identity-deploy-实际编号.json --apply
 ```
 ```
 
 
-工具先校验两个函数的当前版本和旧代码哈希,再恢复原公开函数、删除本次新增同步函数;遇并发修改停止。中途失败也保留prepared回执,未改的原函数无需恢复;若新增请求结果未知且未取得ID,先按函数名核对,勿盲删。静态文件用其独立backup/journal回退。目录配置的私有备份由维护人核对后恢复对应字段,不覆盖其他并发设置;停止计划任务。业务原记录一直未迁移,无需重跑工作坊。
+工具先校验四函数的当前版本和旧代码哈希,再恢复三个原函数、删除本次新增同步函数;遇并发修改停止。中途失败也保留prepared回执,未改的原函数无需恢复;若新增请求结果未知且未取得ID,先按函数名核对,勿盲删。静态文件用其独立backup/journal回退。目录配置的私有备份由维护人核对后恢复对应字段,不覆盖其他并发设置;停止可选计划任务。业务原记录一直未迁移,无需重跑工作坊。
 
 
 ## 本机验证
 ## 本机验证
 
 

+ 4 - 1
scripts/build-five-views-package.mjs

@@ -35,7 +35,10 @@ if (!process.argv.includes('--manifest-only')) {
     '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);
     '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');
   const identityIndex = process.argv.indexOf('--identity-base');
   if (identityIndex >= 0) {
   if (identityIndex >= 0) {
-    const functions = buildIdentityFunctions(JSON.parse(fs.readFileSync(process.argv[identityIndex + 1], 'utf8')));
+    const uploadIndex = process.argv.indexOf('--identity-upload-bases');
+    const functions = buildIdentityFunctions(JSON.parse(fs.readFileSync(process.argv[identityIndex + 1], 'utf8')), {
+      uploadBases: uploadIndex < 0 ? [] : JSON.parse(fs.readFileSync(process.argv[uploadIndex + 1], 'utf8')),
+    });
     const dist = path.join(out, 'server/identity/dist'); fs.mkdirSync(dist, { recursive: true });
     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);
     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));
     fs.writeFileSync(path.join(dist, 'manifest.json'), JSON.stringify({ version: 'ksf.wecom-identity.20261103.v1', functions: functions.map(({ code, ...rest }) => rest) }, null, 2));

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

@@ -3,7 +3,7 @@ import path from 'node:path';
 import crypto from 'node:crypto';
 import crypto from 'node:crypto';
 import { fileURLToPath } from 'node:url';
 import { fileURLToPath } from 'node:url';
 
 
-export const RELEASE = 'ksf.workshop-board.20261103.v4';
+export const RELEASE = 'ksf.workshop-board.20261103.v5';
 export const FILES = Object.freeze([
 export const FILES = Object.freeze([
   'src/layout/kf-app.js', 'src/layout/kf-nav.js', 'src/layout/sample-shell.js',
   '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',
   'src/utils/router.js', 'src/utils/http.js', 'src/services/workshop-data.js',

+ 13 - 4
server/identity/build-release.mjs

@@ -3,28 +3,37 @@ import path from 'node:path';
 import crypto from 'node:crypto';
 import crypto from 'node:crypto';
 import { fileURLToPath } from 'node:url';
 import { fileURLToPath } from 'node:url';
 
 
-export function buildIdentityFunctions(base) {
+export function buildIdentityFunctions(base, { uploadBases = [] } = {}) {
   const directory = path.dirname(fileURLToPath(import.meta.url));
   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 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});';
   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');
   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 overlay = (row) => {
+    if (typeof row.code !== 'string' || row.code.split(hook).length !== 2 || row.code.includes('attachWecomEmployeeHandlers')) throw Error('UNEXPECTED_UPLOAD_FUNCTION');
+    return source + '\n' + row.code.replace(hook, hook + '\n    attachWecomEmployeeHandlers(app,{store,settings});');
+  };
+  const extended = overlay(base);
   const call = 'runDatabaseFunction("ksfGetPublicBoard",request,response,';
   const call = 'runDatabaseFunction("ksfGetPublicBoard",request,response,';
   if (extended.split(call).length !== 2) throw Error('UNEXPECTED_FUNCTION_ENTRY');
   if (extended.split(call).length !== 2) throw Error('UNEXPECTED_FUNCTION_ENTRY');
   const result = [
   const result = [
     { name: 'ksfGetPublicBoard', objectId: base.objectId, path: base.path, priorSha256: crypto.createHash('sha256').update(base.code).digest('hex'), code: extended },
     { 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,') },
     { name: 'ksfSyncWecomEmployee', path: '/ksfSyncWecomEmployee', priorSha256: null, code: extended.replace(call, 'runDatabaseFunction("ksfSyncWecomEmployee",request,response,') },
   ];
   ];
+  if (uploadBases.length && (uploadBases.length !== 2 || new Set(uploadBases.map((r) => r.name)).size !== 2 || uploadBases.some((r) => !['ksfStartWorkshopSession', 'ksfSaveWorkshopTask'].includes(r.name)))) throw Error('UNEXPECTED_UPLOAD_BASES');
+  for (const row of uploadBases) {
+    if (row.code.split('runDatabaseFunction(' + JSON.stringify(row.name) + ',request,response,').length !== 2) throw Error('UNEXPECTED_FUNCTION_ENTRY');
+    result.push({ name: row.name, objectId: row.objectId, path: row.path, priorSha256: crypto.createHash('sha256').update(row.code).digest('hex'), code: overlay(row) });
+  }
   for (const entry of result) { new Function(entry.code); entry.sha256 = crypto.createHash('sha256').update(entry.code).digest('hex'); }
   for (const entry of result) { new Function(entry.code); entry.sha256 = crypto.createHash('sha256').update(entry.code).digest('hex'); }
   return result;
   return result;
 }
 }
 
 
 if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
 if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
   const args = process.argv.slice(2);
   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');
+  if (![4, 6].includes(args.length) || args[0] !== '--base' || args[2] !== '--out' || args.length === 6 && args[4] !== '--upload-bases') throw Error('Use --base PRIVATE_FUNCTION_SNAPSHOT --out NEW_ABSOLUTE_DIRECTORY [--upload-bases PRIVATE_UPLOAD_SNAPSHOTS]');
   const out = args[3];
   const out = args[3];
   if (!path.isAbsolute(out) || fs.existsSync(out)) throw Error('NEW_ABSOLUTE_DIRECTORY_REQUIRED');
   if (!path.isAbsolute(out) || fs.existsSync(out)) throw Error('NEW_ABSOLUTE_DIRECTORY_REQUIRED');
-  const entries = buildIdentityFunctions(JSON.parse(fs.readFileSync(args[1], 'utf8')));
+  const entries = buildIdentityFunctions(JSON.parse(fs.readFileSync(args[1], 'utf8')), { uploadBases: args[5] ? JSON.parse(fs.readFileSync(args[5], 'utf8')) : [] });
   fs.mkdirSync(out, { recursive: true });
   fs.mkdirSync(out, { recursive: true });
   for (const entry of entries) fs.writeFileSync(path.join(out, entry.name + '.js'), entry.code);
   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));
   fs.writeFileSync(path.join(out, 'manifest.json'), JSON.stringify({ version: 'ksf.wecom-identity.20261103.v1', functions: entries.map(({ code, ...rest }) => rest) }, null, 2));

+ 46 - 8
server/identity/employee-handlers.mjs

@@ -25,6 +25,51 @@ export function attachWecomEmployeeHandlers(app, { store, settings, fetchImpl =
       source: 'wecom_sender', nameVerified: false, verifiedAt: null,
       source: 'wecom_sender', nameVerified: false, verifiedAt: null,
     };
     };
   }
   }
+  async function storeProfiles(actor, input, planned) {
+    return app.operation(actor, 'wecom-employee-sync', input, 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', '通讯录配置已变化,请重新核对');
+      const employees = { ...runtime.value.wecomEmployees, ...Object.fromEntries(planned) };
+      await s.update('KsfRuntimeConfig', runtime.objectId, { value: { ...runtime.value, wecomEmployees: employees } });
+      // Make the newly verified name available to the current upload's receipt too.
+      settings.wecomEmployees = employees;
+      return { profiles: [...new Map([...planned.values()].map((v) => [v.key, v])).values()], accountsChecked: planned.size, applied: true };
+    });
+  }
+  for (const name of ['ksfStartWorkshopSession', 'ksfSaveWorkshopTask']) {
+    const original = app.handlers[name];
+    if (!original) continue;
+    app.handlers[name] = async (p, actor) => {
+      need(actor, 'ingest:write');
+      let account, session;
+      if (name === 'ksfStartWorkshopSession') {
+        account = p?.participant?.account;
+        if (account?.identifierType === 'wecom_aibot_sender' &&
+            (p.participant.identityBasis !== 'channel_context' || p.agent?.account?.platform !== 'wecom' || p.agent.account.accountId !== account.scopeId || p.conversation?.channel !== 'wecom')) fail('IDENTITY_CONFLICT', '当前发送者与数字人作用域不一致');
+      } else {
+        if (typeof p?.sessionId !== 'string') fail('VALIDATION_FAILED', '需提供真实会话编号');
+        session = await store.get('KfSession', p.sessionId);
+        if (!session) fail('REFERENCE_NOT_FOUND', '会话不存在');
+        if (!actor.scopes.includes('*') && session.meta?.ownerId !== actor.id) fail('FORBIDDEN', '会话不属于当前服务身份');
+        account = session.meta?.attribution?.participantAccount;
+      }
+      if (!directory.accountKey(account)) return original(p, actor);
+      if (settings.wecomDirectory?.enabled === true) {
+        if (typeof p.requestId !== 'string' || !p.requestId.trim()) fail('VALIDATION_FAILED', '业务请求键缺失');
+        // Resolve before the business write: never save a report and then report a lookup failure.
+        const profile = await directory.resolve(account);
+        const planned = new Map([[directory.accountKey(account), profile]]);
+        await storeProfiles(actor, { requestId: 'employee-upload:' + hash(canonical([name, p.requestId])), businessRequestId: p.requestId, operation: name,
+          accountKey: directory.accountKey(account), ...(session ? { sessionId: session.objectId } : {}) }, planned);
+      }
+      const result = await original(p, actor);
+      const employee = publicProfile({ meta: { attribution: { participantAccount: account } } });
+      return { ...result, employee, employeeIdentity: { automatic: true, status: employee?.nameVerified ? 'verified' : 'pending',
+        ...(employee?.nameVerified ? {} : { reason: 'WECOM_DIRECTORY_NOT_CONFIGURED' }) } };
+    };
+  }
   app.handlers.ksfGetPublicBoard = async (p, actor) => {
   app.handlers.ksfGetPublicBoard = async (p, actor) => {
     const result = await originalBoard(p, actor);
     const result = await originalBoard(p, actor);
     const sessions = new Map();
     const sessions = new Map();
@@ -65,13 +110,6 @@ export function attachWecomEmployeeHandlers(app, { store, settings, fetchImpl =
     }
     }
     const result = { profiles: [...new Map([...planned.values()].map((v) => [v.key, v])).values()], sessionsChecked: ids.length, accountsChecked: planned.size, applied: false };
     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;
     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 };
-    });
+    return { ...await storeProfiles(actor, p, planned), sessionsChecked: ids.length };
   };
   };
 }
 }

+ 4 - 4
server/identity/manage.mjs

@@ -64,11 +64,11 @@ export async function manage({ args, request, invoke, releaseDir = path.join(roo
   if (command === 'rollback') {
   if (command === 'rollback') {
     const receiptFile = value('--receipt'); if (!receiptFile) fail('USAGE');
     const receiptFile = value('--receipt'); if (!receiptFile) fail('USAGE');
     const receipt = privateJson(receiptFile);
     const receipt = privateJson(receiptFile);
-    if (receipt.version !== 'ksf.wecom-identity.20261103.v1' || !Array.isArray(receipt.entries) || receipt.entries.length !== 2) fail('INVALID_RECEIPT');
+    if (receipt.version !== 'ksf.wecom-identity.20261103.v1' || !Array.isArray(receipt.entries) || ![2, 4].includes(receipt.entries.length)) fail('INVALID_RECEIPT');
     const restore = [];
     const restore = [];
-    if (new Set(receipt.entries.map((e) => e.name)).size !== 2) fail('INVALID_RECEIPT');
+    if (new Set(receipt.entries.map((e) => e.name)).size !== receipt.entries.length) fail('INVALID_RECEIPT');
     for (const entry of receipt.entries) {
     for (const entry of receipt.entries) {
-      if (!['ksfGetPublicBoard', 'ksfSyncWecomEmployee'].includes(entry.name)) fail('INVALID_RECEIPT');
+      if (!['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 (!entry.id) { if ((await scan(entry.name)).length) fail('UNKNOWN_CREATED_FUNCTION'); continue; }
       if (!/^[\w-]+$/.test(entry.id)) fail('INVALID_RECEIPT');
       if (!/^[\w-]+$/.test(entry.id)) fail('INVALID_RECEIPT');
       const row = await request('GET', '/classes/Function/' + entry.id);
       const row = await request('GET', '/classes/Function/' + entry.id);
@@ -107,7 +107,7 @@ export async function manage({ args, request, invoke, releaseDir = path.join(roo
   const receiptFile = path.join(receiptDir, 'identity-deploy-' + crypto.randomUUID() + '.json');
   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 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();
   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.
+  // Public projection is installed last; preserve the journal if any earlier function fails.
   for (const entry of [...planned].reverse()) {
   for (const entry of [...planned].reverse()) {
     const current = await scan(entry.name);
     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');
     if (entry.prior ? current.length !== 1 || sha(current[0].code) !== entry.priorSha256 : current.length !== 0) fail('CONCURRENT_FUNCTION_CHANGE');

+ 27 - 3
test/wecom-directory.test.js

@@ -59,15 +59,17 @@ async function appFixture() {
   ];
   ];
   const raw = { ...blank(), session: sessions.map((s, i) => ({ objectId: s.objectId, studentName: s.studentName, meta: { participantKey: 'workshop-participant-' + i } })),
   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' }] };
     artifact: [{ objectId: 'a', ownerName: '历史占位名', ownerSession: 'session1', rawText: '正文不能修改' }], project: [{ objectId: 'p', ownerName: '历史占位名', ownerSession: 'session2' }] };
-  const writes = [], audits = [];
+  const writes = [], audits = [], business = [];
   const runtime = { objectId: 'runtime-id', key: 'runtime', value: structuredClone(settings) };
   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 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 fail = (code) => { throw Object.assign(new Error(code), { code }); };
-  const app = { handlers: { ksfGetPublicBoard: async () => structuredClone(raw) }, fail, hash, canonical,
+  const app = { handlers: { ksfGetPublicBoard: async () => structuredClone(raw),
+    ksfStartWorkshopSession: async (p) => { business.push({ name: 'start', p }); return { sessionId: 'new-session' }; },
+    ksfSaveWorkshopTask: async (p) => { business.push({ name: 'save', p }); return { artifactId: 'new-artifact' }; } }, fail, hash, canonical,
     need: (actor, scope) => { if (!actor) fail('AUTH_REQUIRED'); if (!actor.scopes.some((s) => s === '*' || s === scope)) fail('FORBIDDEN'); },
     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); } };
     operation: async (actor, name, p, fn) => { audits.push({ name, p }); return fn(store); } };
   attachWecomEmployeeHandlers(app, { store, settings, fetchImpl: api.fetchImpl });
   attachWecomEmployeeHandlers(app, { store, settings, fetchImpl: api.fetchImpl });
-  return { app, raw, sessions, writes, audits, settings, api, runtime, actor: { id: 'admin', scopes: ['*'] } };
+  return { app, raw, sessions, writes, audits, business, settings, api, runtime, actor: { id: 'admin', scopes: ['*'] } };
 }
 }
 test('public overlay supplies directory names and stable identity across workshops while preserving history', async () => {
 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({});
   const f = await appFixture(), before = structuredClone(f.raw), result = await f.app.handlers.ksfGetPublicBoard({});
@@ -105,3 +107,25 @@ test('same-name employees are kept separate even in one department, without losi
   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 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);
   const db = buildModel(raw); assert.equal(db.members.length, 2); assert.equal(db.members.find((m) => m.name.includes('bbbbbbbb')).artifactIds.length, 1);
 });
 });
+test('digital-human start and report upload automatically resolve the employee without a separate login or identity API call', async () => {
+  const f = await appFixture(); f.settings.wecomEmployees = {};
+  const actor = { id: 'service1', scopes: ['ingest:write'] };
+  const started = await f.app.handlers.ksfStartWorkshopSession({ requestId: 'start-1', participant: { identityBasis: 'channel_context', account: account() }, agent: { account: { platform: 'wecom', accountId: 'test-bot' } }, conversation: { channel: 'wecom' } }, actor);
+  assert.equal(started.employee.name, '测试成员甲'); assert.equal(started.employeeIdentity.status, 'verified'); assert.equal(started.employeeIdentity.automatic, true);
+  const uploaded = await f.app.handlers.ksfSaveWorkshopTask({ requestId: 'save-1', sessionId: 'session1', title: '新报告' }, actor);
+  assert.equal(uploaded.artifactId, 'new-artifact'); assert.equal(uploaded.employee.name, '测试成员甲'); assert.equal(f.business.length, 2);
+  assert.equal(f.audits.length, 2); assert.ok(f.audits.every((a) => a.name === 'wecom-employee-sync'));
+  assert.equal(f.business[1].p.requestId, 'save-1'); assert.equal(f.sessions[0].studentName, '历史占位名');
+});
+test('upload uses the saved sender account, refuses wrong bot and stops failed configured lookups before saving the report', async () => {
+  const f = await appFixture();
+  await assert.rejects(() => f.app.handlers.ksfStartWorkshopSession({ requestId: 'wrong-bot', participant: { identityBasis: 'channel_context', account: account() }, agent: { account: { platform: 'wecom', accountId: 'other-bot' } }, conversation: { channel: 'wecom' } }, f.actor), { code: 'IDENTITY_CONFLICT' });
+  delete f.settings.wecomDirectory.bots['test-bot'].userIds['opaque-test-sender'];
+  await assert.rejects(() => f.app.handlers.ksfSaveWorkshopTask({ requestId: 'bad-map', sessionId: 'session1', account: { name: '正文伪造姓名' } }, f.actor), { code: 'WECOM_SENDER_MAPPING_REQUIRED' });
+  assert.equal(f.business.length, 0); assert.equal(f.writes.length, 0);
+});
+test('unconfigured name service keeps sender attribution and marks the upload receipt pending instead of claiming a real name', async () => {
+  const f = await appFixture(); delete f.settings.wecomDirectory; f.settings.wecomEmployees = {};
+  const result = await f.app.handlers.ksfSaveWorkshopTask({ requestId: 'pending-save', sessionId: 'session1' }, f.actor);
+  assert.equal(result.artifactId, 'new-artifact'); assert.equal(result.employeeIdentity.status, 'pending'); assert.equal(result.employee.nameVerified, false); assert.equal(f.writes.length, 0);
+});

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

@@ -30,6 +30,19 @@ function setup(t) {
 test('production overlay builds exact executable entries while rejecting unknown base shapes', () => {
 test('production overlay builds exact executable entries while rejecting unknown base shapes', () => {
   assert.throws(() => buildIdentityFunctions({ name: 'other', code: '' }), /UNEXPECTED_PUBLIC_FUNCTION/);
   assert.throws(() => buildIdentityFunctions({ name: 'other', code: '' }), /UNEXPECTED_PUBLIC_FUNCTION/);
 });
 });
+test('upload hooks overlay each current production function without replacing its original business implementation', async (t) => {
+  const f = setup(t), uploads = ['ksfStartWorkshopSession', 'ksfSaveWorkshopTask'].map((name) => ({ ...f.base, name, objectId: name + '-id', path: '/' + name, code: f.base.code.replace('"ksfGetPublicBoard"', JSON.stringify(name)) }));
+  const result = buildIdentityFunctions(f.base, { uploadBases: uploads });
+  assert.equal(result.length, 4); assert.deepEqual(result.map((r) => r.name).sort(), ['ksfGetPublicBoard', 'ksfSyncWecomEmployee', 'ksfStartWorkshopSession', 'ksfSaveWorkshopTask'].sort());
+  for (const source of uploads) { const built = result.find((r) => r.name === source.name); assert.equal(built.priorSha256, sha(source.code)); assert.ok(built.code.includes('runDatabaseFunction(' + JSON.stringify(source.name))); new Function(built.code); }
+  assert.throws(() => buildIdentityFunctions(f.base, { uploadBases: [uploads[0]] }), /UNEXPECTED_UPLOAD_BASES/);
+  for (const entry of result) fs.writeFileSync(path.join(f.dir, entry.name + '.js'), entry.code);
+  fs.writeFileSync(path.join(f.dir, 'manifest.json'), JSON.stringify({ version: 'ksf.wecom-identity.20261103.v1', functions: result.map(({ code, ...r }) => r) }));
+  f.rows.push(...structuredClone(uploads));
+  const deployed = await f.run(['deploy', '--apply', '--receipt-dir', f.dir]); assert.equal(deployed.functions, 4); assert.equal(f.rows.length, 4);
+  await f.run(['rollback', '--receipt', deployed.receipt, '--apply']); assert.equal(f.rows.length, 3);
+  for (const source of [f.base, ...uploads]) assert.equal(f.rows.find((r) => r.name === source.name).code, source.code);
+});
 test('function deployment is read-only by default, checks exact baseline, repeats without writes and restores old public code', async (t) => {
 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 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 applied = await f.run(['deploy', '--apply', '--receipt-dir', f.dir]); assert.equal(applied.applied, true); assert.equal(f.rows.length, 2);