Explorar o código

fix(workshop): archive deployed account confirmation and session fixes

pengzhikang hai 3 horas
pai
achega
855c8b0a13

+ 2 - 0
deploy/account-binding-short-links-20261112/.gitattributes

@@ -0,0 +1,2 @@
+# Deployment patches verify these historical fixtures by their exact byte hash.
+* -text whitespace=cr-at-eol

+ 155 - 0
deploy/account-binding-short-links-20261112/previous-account-binding.mjs

@@ -0,0 +1,155 @@
+import { WORKSHOP_ROSTER } from '../../src/core/workshop-roster.js';
+
+/** Explicit account links, never name-based merging. Original attribution stays intact. */
+export function attachAccountBindingHandlers(app, { store, settings, roster = WORKSHOP_ROSTER, fetchImpl = fetch, now = Date.now }) {
+  const { fail, need, hash, canonical } = app;
+  const schema = 'ksf.workshop-account-binding.v1';
+  const companyId = roster.companyId;
+  const exact = (p, keys) => { if (!p || typeof p !== 'object' || Array.isArray(p) || Object.keys(p).some(k => !keys.includes(k))) fail('VALIDATION_FAILED', '身份请求包含未允许字段'); };
+  const profileKey = key => { if (!/^participant-(?:account:[a-f0-9]{64}|registration:[a-f0-9-]{36})$/.test(key || '')) fail('VALIDATION_FAILED', '员工档案键无效'); return key; };
+  const aliasKey = (owner, key) => 'workshop-account-alias:' + hash(canonical([owner, profileKey(key)]));
+  const memberKey = id => 'platform-account:' + hash(JSON.stringify([roster.platformAppId, companyId, id]));
+  const member = username => {
+    const matches = roster.members.filter(r => r.username === username);
+    if (matches.length !== 1) fail('ACCOUNT_NOT_REGISTERED', '该账号不在工作坊名册,请核对账号或联系管理员');
+    return matches[0];
+  };
+  const read = async (s, key, lock = false) => {
+    const rows = await s.list('KsfRuntimeConfig', { where: { key }, limit: 2, forUpdate: lock });
+    if (rows.length > 1) fail('ACCOUNT_BINDING_CONFLICT', '账号绑定记录冲突,请联系管理员');
+    return rows[0];
+  };
+  const checked = (value, owner, key) => {
+    if (value?.schema !== schema || value.ownerId !== owner || value.profileKey !== key || value.companyId !== companyId) fail('ACCOUNT_BINDING_CONFLICT', '账号绑定归属冲突');
+    return value;
+  };
+  const bindingOf = value => {
+    if (!value?.binding) fail('ACCOUNT_BINDING_REQUIRED', '请先完成本人账号绑定,再保存成果');
+    const b = value.binding, m = member(b.username);
+    if (b.schema !== schema || b.memberKey !== m.id || b.memberKey !== memberKey(b.platformUserId) || b.companyId !== companyId || !['platform_session', 'verified_historical_repair'].includes(b.source)) fail('ACCOUNT_BINDING_CONFLICT', '账号绑定校验失败');
+    return b;
+  };
+  const summary = b => ({ schema, status: 'bound', memberKey: b.memberKey, username: b.username, displayName: b.displayName, companyId, verifiedAt: b.verifiedAt, source: b.source });
+
+  // Called only by the HTTP adapter for confirmation, never from model-supplied params.
+  app.authenticateWorkshopPlatform = async headers => {
+    const h = Object.fromEntries(Object.entries(headers || {}).map(([k,v]) => [k.toLowerCase(),v]));
+    const token = h['x-fmode-session-token'];
+    if (typeof token !== 'string' || !token || token.length > 4000) fail('AUTH_REQUIRED', '请在绑定页面登录本人账号');
+    let response;
+    try { response = await fetchImpl('https://server.fmode.cn/parse/users/me', { headers: { 'X-Parse-Application-Id': roster.platformAppId, 'X-Parse-Session-Token': token }, redirect: 'error', signal: AbortSignal.timeout(10000) }); }
+    catch { fail('ACCOUNT_VERIFICATION_UNAVAILABLE', '暂时无法核验登录,请稍后重试'); }
+    if (!response.ok) fail('AUTH_REQUIRED', '登录已失效,请重新登录');
+    let user; try { user = await response.json(); } catch { fail('ACCOUNT_VERIFICATION_UNAVAILABLE', '登录服务返回不完整'); }
+    if (user.company?.__type !== 'Pointer' || user.company.className !== 'Company' || user.company.objectId !== companyId) fail('COMPANY_FORBIDDEN', '请使用康师傅工作坊账号');
+    const m = member(user.username);
+    if (!user.objectId || memberKey(user.objectId) !== m.id) fail('ACCOUNT_BINDING_CONFLICT', '账号编号与名册不一致,请联系管理员');
+    return { id: user.objectId, kind: 'workshop-platform', username: user.username, memberKey: m.id, companyId };
+  };
+  app.handlers.ksfWorkshopAccount = async (p, actor) => {
+    if (p.action === 'confirm') {
+      exact(p, ['requestId', 'action', 'ticket']);
+      if (actor?.kind !== 'workshop-platform') fail('AUTH_REQUIRED', '必须由本人登录确认绑定');
+      const ticket = app.readReceipt(p.ticket);
+      if (ticket.type !== 'workshop-account-link' || ticket.expiresAt <= now()) fail('ACCOUNT_LINK_EXPIRED', '绑定链接已过期,请回对话重新获取');
+      if (ticket.username !== actor.username) fail('ACCOUNT_BINDING_CONFLICT', '当前登录账号与对话中确认的账号不一致');
+      return store.transaction(async s => {
+        await s.lock(ticket.aliasKey);
+        const row = await read(s, ticket.aliasKey, true);
+        if (!row) fail('ACCOUNT_LINK_EXPIRED', '绑定链接已失效');
+        const v = checked(row.value, ticket.ownerId, ticket.profileKey);
+        if (v.binding) {
+          const b = bindingOf(v);
+          if (b.memberKey !== actor.memberKey) fail('ACCOUNT_BINDING_CONFLICT', '此对话身份已绑定其他账号');
+          return summary(b);
+        }
+        if (v.pending?.nonce !== ticket.nonce || v.pending.expiresAt <= now()) fail('ACCOUNT_LINK_EXPIRED', '绑定链接已更新,请使用最新链接');
+        const m = member(actor.username), b = { schema, memberKey: actor.memberKey, username: actor.username, platformUserId: actor.id, companyId, displayName: v.pending.displayName || m.name, verifiedAt: new Date(now()).toISOString(), source: 'platform_session' };
+        await s.update('KsfRuntimeConfig', row.objectId, { value: { ...v, binding: b, pending: null } });
+        return summary(b);
+      });
+    }
+    exact(p, ['requestId', 'action', 'participantProfileKey', ...(p.action === 'prepare' ? ['username', 'displayName'] : [])]);
+    if (!['prepare', 'status'].includes(p.action)) fail('VALIDATION_FAILED', '身份操作无效');
+    need(actor, p.action === 'prepare' ? 'ingest:write' : 'data:read');
+    const key = profileKey(p.participantProfileKey), alias = aliasKey(actor.id, key);
+    if (p.action === 'status') {
+      const row = await read(store, alias);
+      return row?.value.binding ? summary(bindingOf(checked(row.value, actor.id, key))) : { schema, status: 'unbound', readOnly: true, databaseWrites: 0 };
+    }
+    member(p.username);
+    if (typeof p.displayName !== 'string' || !p.displayName.trim() || [...p.displayName.trim()].length > 40 || /[\p{Cc}\p{Cf}]/u.test(p.displayName)) fail('VALIDATION_FAILED', '请提供1至40字称呼');
+    return store.transaction(async s => {
+      await s.lock(alias);
+      const row = await read(s, alias, true);
+      const v = row ? checked(row.value, actor.id, key) : { schema, ownerId: actor.id, profileKey: key, companyId };
+      if (v.binding) {
+        const b = bindingOf(v);
+        if (b.username !== p.username) fail('ACCOUNT_BINDING_CONFLICT', '此身份已绑定其他账号,不得覆盖');
+        return summary(b);
+      }
+      const pending = { username: p.username, displayName: p.displayName.trim(), nonce: hash(canonical([alias, now(), p.requestId])), expiresAt: now() + 30 * 60000 };
+      const value = { ...v, pending };
+      if (row) await s.update('KsfRuntimeConfig', row.objectId, { value }); else await s.create('KsfRuntimeConfig', { key: alias, value });
+      const ticket = app.signedReceipt({ type: 'workshop-account-link', aliasKey: alias, ownerId: actor.id, profileKey: key, ...pending });
+      return { schema, status: 'awaiting_account_confirmation', username: p.username, expiresAt: new Date(pending.expiresAt).toISOString(), confirmationUrl: settings.functionsUrl + '?path=/ksfWorkshopAccount#ticket=' + encodeURIComponent(ticket) };
+    });
+  };
+  async function resolve(ownerId, key, account) {
+    const row = await read(store, aliasKey(ownerId, key));
+    if (!row) fail('ACCOUNT_BINDING_REQUIRED', '请先完成本人账号绑定,再保存成果');
+    const b = bindingOf(checked(row.value, ownerId, key));
+    if (account?.platform !== 'fmode' || account.tenantId !== companyId || account.accountName !== b.username || ![null,b.username,b.platformUserId].includes(account.accountId ?? null)) fail('ACCOUNT_BINDING_CONFLICT', '提交身份与已绑定账号不一致');
+    return b;
+  }
+  const originalStart = app.handlers.ksfStartWorkshopSession;
+  app.handlers.ksfStartWorkshopSession = async (p, actor) => {
+    if (p.participant?.account?.platform !== 'fmode') return originalStart(p, actor);
+    need(actor, 'ingest:write');
+    const b = await resolve(actor.id, p.participant.key, p.participant.account);
+    const result = await originalStart(p, actor);
+    // The alias is authoritative and immutable; no need to rewrite historical session keys.
+    return { ...result, memberKey: b.memberKey, accountBinding: summary(b) };
+  };
+  const originalSave = app.handlers.ksfSaveWorkshopTask;
+  app.handlers.ksfSaveWorkshopTask = async (p, actor) => {
+    need(actor, 'ingest:write');
+    const session = await store.get('KfSession', p.sessionId);
+    const a = session?.meta?.attribution;
+    if (a?.participantAccount?.platform === 'fmode') await sessionBinding(session);
+    return originalSave(p, actor);
+  };
+  async function sessionBinding(session) {
+    const a = session?.meta?.attribution;
+    if (!a || a.participantAccount?.platform !== 'fmode') return null;
+    // Stored full binding is used only for the narrowly audited historical repair.
+    if (a.accountBinding) return bindingOf({ binding: a.accountBinding });
+    // The local profile key is part of the original hash; start records it privately.
+    if (!a.accountProfileKey) fail('ACCOUNT_BINDING_REQUIRED', '旧会话需要核对账号关联,请联系管理员');
+    return resolve(session.meta.ownerId, a.accountProfileKey, a.participantAccount);
+  }
+  const originalBoard = app.handlers.ksfGetPublicBoard;
+  app.handlers.ksfGetPublicBoard = async (p, actor) => {
+    const board = await originalBoard(p, actor), cached = new Map();
+    async function lookup(id) {
+      if (!id) return null;
+      if (!cached.has(id)) cached.set(id, (async () => {
+        const row = await store.get('KfSession', id);
+        if (!row || row.recordState === 'archived') return null;
+        try { return await sessionBinding(row); } catch (e) { if (e.code === 'ACCOUNT_BINDING_REQUIRED') return null; throw e; }
+      })());
+      return cached.get(id);
+    }
+    for (const kind of ['session','artifact','project','opportunity']) for (const row of board[kind] || []) {
+      const id = kind === 'session' ? row.objectId : row.sessionRef?.objectId || row.ownerSession || row.sourceSession;
+      const b = await lookup(id);
+      if (b) row.memberKey = b.memberKey;
+    }
+    return board;
+  };
+  const originalContract = app.handlers.ksfGetIngestContract;
+  app.handlers.ksfGetIngestContract = async (p, actor) => {
+    const r = await originalContract(p, actor);
+    return { ...r, workshop: { ...r.workshop, accountBinding: { schema, enabled: true, requiredFor: ['fmode'], function: 'ksfWorkshopAccount', firstUse: 'confirm-display-name-and-account', proof: 'platform-session', canonicalMember: 'platform-account', oneMemberPerAccount: true, unboundSubmission: 'reject', historicalAttributionPreserved: true } } };
+  };
+}

+ 174 - 0
deploy/account-binding-short-links-20261112/released-account-binding.mjs

@@ -0,0 +1,174 @@
+import { WORKSHOP_ROSTER } from '../../src/core/workshop-roster.js';
+
+/** Explicit account links, never name-based merging. Original attribution stays intact. */
+export function attachAccountBindingHandlers(app, { store, settings, roster = WORKSHOP_ROSTER, fetchImpl = fetch, now = Date.now }) {
+  const { fail, need, hash, canonical } = app;
+  const schema = 'ksf.workshop-account-binding.v1';
+  const linkSchema = 'ksf.workshop-account-link-reference.v1';
+  const companyId = roster.companyId;
+  const exact = (p, keys) => { if (!p || typeof p !== 'object' || Array.isArray(p) || Object.keys(p).some(k => !keys.includes(k))) fail('VALIDATION_FAILED', '身份请求包含未允许字段'); };
+  const profileKey = key => { if (!/^participant-(?:account:[a-f0-9]{64}|registration:[a-f0-9-]{36})$/.test(key || '')) fail('VALIDATION_FAILED', '员工档案键无效'); return key; };
+  const aliasKey = (owner, key) => 'workshop-account-alias:' + hash(canonical([owner, profileKey(key)]));
+  const memberKey = id => 'platform-account:' + hash(JSON.stringify([roster.platformAppId, companyId, id]));
+  const member = username => {
+    const matches = roster.members.filter(r => r.username === username);
+    if (matches.length !== 1) fail('ACCOUNT_NOT_REGISTERED', '该账号不在工作坊名册,请核对账号或联系管理员');
+    return matches[0];
+  };
+  const read = async (s, key, lock = false) => {
+    const rows = await s.list('KsfRuntimeConfig', { where: { key }, limit: 2, forUpdate: lock });
+    if (rows.length > 1) fail('ACCOUNT_BINDING_CONFLICT', '账号绑定记录冲突,请联系管理员');
+    return rows[0];
+  };
+  const checked = (value, owner, key) => {
+    if (value?.schema !== schema || value.ownerId !== owner || value.profileKey !== key || value.companyId !== companyId) fail('ACCOUNT_BINDING_CONFLICT', '账号绑定归属冲突');
+    return value;
+  };
+  const bindingOf = value => {
+    if (!value?.binding) fail('ACCOUNT_BINDING_REQUIRED', '请先完成本人账号绑定,再保存成果');
+    const b = value.binding, m = member(b.username);
+    if (b.schema !== schema || b.memberKey !== m.id || b.memberKey !== memberKey(b.platformUserId) || b.companyId !== companyId || !['platform_session', 'verified_historical_repair'].includes(b.source)) fail('ACCOUNT_BINDING_CONFLICT', '账号绑定校验失败');
+    return b;
+  };
+  const summary = b => ({ schema, status: 'bound', memberKey: b.memberKey, username: b.username, displayName: b.displayName, companyId, verifiedAt: b.verifiedAt, source: b.source });
+  async function confirmationTicket(value) {
+    // Keep signed payloads out of chat, whose long-string filters can truncate them.
+    // A reference retains 132 bits of the existing HMAC and resolves only server-side.
+    if (typeof value === 'string' && /^ksfl_[A-Za-z0-9_-]{22}$/.test(value)) {
+      const row = await read(store, 'workshop-account-link:' + hash(value));
+      if (row?.value?.schema !== linkSchema || row.value.expiresAt <= now() || typeof row.value.ticket !== 'string') fail('ACCOUNT_LINK_EXPIRED', '绑定链接已失效,请回对话重新获取');
+      return app.readReceipt(row.value.ticket);
+    }
+    // Previously issued signed links remain valid until their original expiry.
+    return app.readReceipt(value);
+  }
+
+  // Called only by the HTTP adapter for confirmation, never from model-supplied params.
+  app.authenticateWorkshopPlatform = async headers => {
+    const h = Object.fromEntries(Object.entries(headers || {}).map(([k,v]) => [k.toLowerCase(),v]));
+    const token = h['x-fmode-session-token'];
+    if (typeof token !== 'string' || !token || token.length > 4000) fail('AUTH_REQUIRED', '请在绑定页面登录本人账号');
+    let response;
+    try { response = await fetchImpl('https://server.fmode.cn/parse/users/me', { headers: { 'X-Parse-Application-Id': roster.platformAppId, 'X-Parse-Session-Token': token }, redirect: 'error', signal: AbortSignal.timeout(10000) }); }
+    catch { fail('ACCOUNT_VERIFICATION_UNAVAILABLE', '暂时无法核验登录,请稍后重试'); }
+    if (!response.ok) fail('AUTH_REQUIRED', '登录已失效,请重新登录');
+    let user; try { user = await response.json(); } catch { fail('ACCOUNT_VERIFICATION_UNAVAILABLE', '登录服务返回不完整'); }
+    if (user.company?.__type !== 'Pointer' || user.company.className !== 'Company' || user.company.objectId !== companyId) fail('COMPANY_FORBIDDEN', '请使用康师傅工作坊账号');
+    const m = member(user.username);
+    if (!user.objectId || memberKey(user.objectId) !== m.id) fail('ACCOUNT_BINDING_CONFLICT', '账号编号与名册不一致,请联系管理员');
+    return { id: user.objectId, kind: 'workshop-platform', username: user.username, memberKey: m.id, companyId };
+  };
+  app.handlers.ksfWorkshopAccount = async (p, actor) => {
+    if (p.action === 'confirm') {
+      exact(p, ['requestId', 'action', 'ticket']);
+      if (actor?.kind !== 'workshop-platform') fail('AUTH_REQUIRED', '必须由本人登录确认绑定');
+      const ticket = await confirmationTicket(p.ticket);
+      if (ticket.type !== 'workshop-account-link' || ticket.expiresAt <= now()) fail('ACCOUNT_LINK_EXPIRED', '绑定链接已过期,请回对话重新获取');
+      if (ticket.username !== actor.username) fail('ACCOUNT_BINDING_CONFLICT', '当前登录账号与对话中确认的账号不一致');
+      return store.transaction(async s => {
+        await s.lock(ticket.aliasKey);
+        const row = await read(s, ticket.aliasKey, true);
+        if (!row) fail('ACCOUNT_LINK_EXPIRED', '绑定链接已失效');
+        const v = checked(row.value, ticket.ownerId, ticket.profileKey);
+        if (v.binding) {
+          const b = bindingOf(v);
+          if (b.memberKey !== actor.memberKey) fail('ACCOUNT_BINDING_CONFLICT', '此对话身份已绑定其他账号');
+          return summary(b);
+        }
+        if (v.pending?.nonce !== ticket.nonce || v.pending.expiresAt <= now()) fail('ACCOUNT_LINK_EXPIRED', '绑定链接已更新,请使用最新链接');
+        const m = member(actor.username), b = { schema, memberKey: actor.memberKey, username: actor.username, platformUserId: actor.id, companyId, displayName: v.pending.displayName || m.name, verifiedAt: new Date(now()).toISOString(), source: 'platform_session' };
+        await s.update('KsfRuntimeConfig', row.objectId, { value: { ...v, binding: b, pending: null } });
+        return summary(b);
+      });
+    }
+    exact(p, ['requestId', 'action', 'participantProfileKey', ...(p.action === 'prepare' ? ['username', 'displayName'] : [])]);
+    if (!['prepare', 'status'].includes(p.action)) fail('VALIDATION_FAILED', '身份操作无效');
+    need(actor, p.action === 'prepare' ? 'ingest:write' : 'data:read');
+    const key = profileKey(p.participantProfileKey), alias = aliasKey(actor.id, key);
+    if (p.action === 'status') {
+      const row = await read(store, alias);
+      return row?.value.binding ? summary(bindingOf(checked(row.value, actor.id, key))) : { schema, status: 'unbound', readOnly: true, databaseWrites: 0 };
+    }
+    member(p.username);
+    if (typeof p.displayName !== 'string' || !p.displayName.trim() || [...p.displayName.trim()].length > 40 || /[\p{Cc}\p{Cf}]/u.test(p.displayName)) fail('VALIDATION_FAILED', '请提供1至40字称呼');
+    return store.transaction(async s => {
+      await s.lock(alias);
+      const row = await read(s, alias, true);
+      const v = row ? checked(row.value, actor.id, key) : { schema, ownerId: actor.id, profileKey: key, companyId };
+      if (v.binding) {
+        const b = bindingOf(v);
+        if (b.username !== p.username) fail('ACCOUNT_BINDING_CONFLICT', '此身份已绑定其他账号,不得覆盖');
+        return summary(b);
+      }
+      const pending = { username: p.username, displayName: p.displayName.trim(), nonce: hash(canonical([alias, now(), p.requestId])), expiresAt: now() + 30 * 60000 };
+      const value = { ...v, pending };
+      if (row) await s.update('KsfRuntimeConfig', row.objectId, { value }); else await s.create('KsfRuntimeConfig', { key: alias, value });
+      const ticket = app.signedReceipt({ type: 'workshop-account-link', aliasKey: alias, ownerId: actor.id, profileKey: key, ...pending });
+      const signature = ticket.split('.').at(-1);
+      if (!/^[A-Za-z0-9_-]{43}$/.test(signature || '')) fail('ACCOUNT_LINK_UNAVAILABLE', '暂时无法生成绑定链接');
+      const reference = 'ksfl_' + signature.slice(0, 22), linkKey = 'workshop-account-link:' + hash(reference);
+      await s.lock(linkKey);
+      const prior = await read(s, linkKey, true);
+      if (prior && (prior.value?.schema !== linkSchema || prior.value.ticket !== ticket)) fail('ACCOUNT_BINDING_CONFLICT', '绑定链接冲突,请重新获取');
+      if (!prior) await s.create('KsfRuntimeConfig', { key: linkKey, value: { schema: linkSchema, expiresAt: pending.expiresAt, ticket } });
+      return { schema, status: 'awaiting_account_confirmation', username: p.username, expiresAt: new Date(pending.expiresAt).toISOString(), confirmationUrl: settings.functionsUrl + '?path=/ksfWorkshopAccount#ticket=' + encodeURIComponent(reference) };
+    });
+  };
+  async function resolve(ownerId, key, account) {
+    const row = await read(store, aliasKey(ownerId, key));
+    if (!row) fail('ACCOUNT_BINDING_REQUIRED', '请先完成本人账号绑定,再保存成果');
+    const b = bindingOf(checked(row.value, ownerId, key));
+    if (account?.platform !== 'fmode' || account.tenantId !== companyId || account.accountName !== b.username || ![null,b.username,b.platformUserId].includes(account.accountId ?? null)) fail('ACCOUNT_BINDING_CONFLICT', '提交身份与已绑定账号不一致');
+    return b;
+  }
+  const originalStart = app.handlers.ksfStartWorkshopSession;
+  app.handlers.ksfStartWorkshopSession = async (p, actor) => {
+    if (p.participant?.account?.platform !== 'fmode') return originalStart(p, actor);
+    need(actor, 'ingest:write');
+    const b = await resolve(actor.id, p.participant.key, p.participant.account);
+    const result = await originalStart(p, actor);
+    // The alias is authoritative and immutable; no need to rewrite historical session keys.
+    return { ...result, memberKey: b.memberKey, accountBinding: summary(b) };
+  };
+  const originalSave = app.handlers.ksfSaveWorkshopTask;
+  app.handlers.ksfSaveWorkshopTask = async (p, actor) => {
+    need(actor, 'ingest:write');
+    const session = await store.get('KfSession', p.sessionId);
+    const a = session?.meta?.attribution;
+    if (a?.participantAccount?.platform === 'fmode') await sessionBinding(session);
+    return originalSave(p, actor);
+  };
+  async function sessionBinding(session) {
+    const a = session?.meta?.attribution;
+    if (!a || a.participantAccount?.platform !== 'fmode') return null;
+    // Stored full binding is used only for the narrowly audited historical repair.
+    if (a.accountBinding) return bindingOf({ binding: a.accountBinding });
+    // The local profile key is part of the original hash; start records it privately.
+    if (!a.accountProfileKey) fail('ACCOUNT_BINDING_REQUIRED', '旧会话需要核对账号关联,请联系管理员');
+    return resolve(session.meta.ownerId, a.accountProfileKey, a.participantAccount);
+  }
+  const originalBoard = app.handlers.ksfGetPublicBoard;
+  app.handlers.ksfGetPublicBoard = async (p, actor) => {
+    const board = await originalBoard(p, actor), cached = new Map();
+    async function lookup(id) {
+      if (!id) return null;
+      if (!cached.has(id)) cached.set(id, (async () => {
+        const row = await store.get('KfSession', id);
+        if (!row || row.recordState === 'archived') return null;
+        try { return await sessionBinding(row); } catch (e) { if (e.code === 'ACCOUNT_BINDING_REQUIRED') return null; throw e; }
+      })());
+      return cached.get(id);
+    }
+    for (const kind of ['session','artifact','project','opportunity']) for (const row of board[kind] || []) {
+      const id = kind === 'session' ? row.objectId : row.sessionRef?.objectId || row.ownerSession || row.sourceSession;
+      const b = await lookup(id);
+      if (b) row.memberKey = b.memberKey;
+    }
+    return board;
+  };
+  const originalContract = app.handlers.ksfGetIngestContract;
+  app.handlers.ksfGetIngestContract = async (p, actor) => {
+    const r = await originalContract(p, actor);
+    return { ...r, workshop: { ...r.workshop, accountBinding: { schema, enabled: true, requiredFor: ['fmode'], function: 'ksfWorkshopAccount', firstUse: 'confirm-display-name-and-account', proof: 'platform-session', canonicalMember: 'platform-account', oneMemberPerAccount: true, unboundSubmission: 'reject', historicalAttributionPreserved: true } } };
+  };
+}

+ 19 - 0
deploy/account-binding-short-links-20261112/session-identity-first-fix.txt

@@ -0,0 +1,19 @@
+  async function resolve(ownerId, key, account, { allowMissingTenant = false } = {}) {
+    const row = await read(store, aliasKey(ownerId, key));
+    if (!row) fail('ACCOUNT_BINDING_REQUIRED', '请先完成本人账号绑定,再保存成果');
+    const b = bindingOf(checked(row.value, ownerId, key));
+    if (account?.platform !== 'fmode' || (account.tenantId !== companyId && !(allowMissingTenant && account.tenantId == null)) || account.accountName !== b.username || ![null,b.username,b.platformUserId].includes(account.accountId ?? null)) fail('ACCOUNT_BINDING_CONFLICT', '提交身份与已绑定账号不一致');
+    return b;
+  }
+  const originalStart = app.handlers.ksfStartWorkshopSession;
+  app.handlers.ksfStartWorkshopSession = async (p, actor) => {
+    if (p.participant?.account?.platform !== 'fmode') return originalStart(p, actor);
+    need(actor, 'ingest:write');
+    const b = await resolve(actor.id, p.participant.key, p.participant.account, { allowMissingTenant: true });
+    // First-use profiles can predate platform confirmation. Fill absent identity
+    // only from its verified immutable alias, without changing the profile key.
+    const account = { ...p.participant.account, tenantId: b.companyId, accountId: p.participant.account.accountId ?? b.platformUserId };
+    const result = await originalStart({ ...p, participant: { ...p.participant, account } }, actor);
+    // The alias is authoritative and immutable; no need to rewrite historical session keys.
+    return { ...result, memberKey: b.memberKey, accountBinding: summary(b) };
+  };

+ 32 - 0
docs/account-backend-source-sync-20261113/README.md

@@ -0,0 +1,32 @@
+# 已上线账号确认与开课修复的源码归档
+
+2026-11-13 将此前已上线的两项后端修复同步到看板维护仓库,防止后续从旧源码构建时覆盖修复。本次生产操作仅为只读核对,未重新部署函数、重启服务、改变绑定或写入报告。
+
+## 修复后的行为
+
+- `ksfWorkshopAccount` 使用短确认引用,完整签名票据保留在私有运行配置中,避免长票据经过聊天时被截断。本人平台登录、企业归属、账号编号、签名、过期时间、nonce、冲突与幂等校验继续生效;旧签名链接兼容其原有有效期。
+- `ksfStartWorkshopSession` 对已完成本人认证、但首次本地档案缺企业编号的情况,从该档案既有且不可变的账号绑定补齐身份后开课。明确的跨企业、用户名或平台用户编号冲突仍拒绝。企业编号已明确的合法旧档案保持原账号字段与会话归属,不改档案键、不新增成员。
+
+这两项修复不提供新的Matrix发送者认证,也不能代替多人并发身份隔离验收。
+
+## 本次归档范围
+
+- 维护源:`server/src/account-binding.mjs`。
+- 行为与边界测试:`server/test/account-binding.test.mjs`、两个`account-*-deploy.test.mjs`。
+- 精确补丁部署器:`scripts/deploy-account-short-links.mjs`、`scripts/deploy-account-session-identity.mjs`。
+- `deploy/account-binding-short-links-20261112/`内三份历史基线及过渡文件供部署器核验,不作为维护源;本目录禁止Git换行转换,确保脚本固定的逐字节SHA-256在不同系统上保持一致。
+
+未归档生产配置、凭据、原始函数备份、会话票据或学员私有对话。Skill安装包、看板界面及研究库改动不在此次提交范围。
+
+## 验证
+
+1. 从生产Function记录只读核对两个函数的编号、名称、路径与完整代码SHA-256,均与已上线版本一致;具体指纹见同目录`verification.json`。
+2. `node --test server/test/account-binding.test.mjs server/test/account-short-links-deploy.test.mjs server/test/account-session-identity-deploy.test.mjs server/test/workshop.test.mjs server/test/application.test.mjs`:60项通过。
+3. `node server/scripts/build.mjs`:34个函数构建并逐个进行语法检查通过,构建产物不作为本次生产部署。
+4. `git diff --check`通过;三份历史基线保留原始字节并核对部署器内固定指纹。
+
+## 后续使用
+
+生产已安装这两个修复,无须为了源码归档再运行apply。两个部署器均在版本不明时停止;已处于对应修复版本时返回alreadyApplied。未来如需部署,应使用正式流程先核当时版本、备份并评估差异,不能绕过指纹保护。
+
+这里的完整函数指纹对应2026-11-13只读核对时的生产代码;它们不是本仓库重新完整构建后所有函数的通用指纹。账号确认和开课当时分别精确替换各自模块/代码块,其他外围代码按其原发行保留。

+ 29 - 0
docs/account-backend-source-sync-20261113/verification.json

@@ -0,0 +1,29 @@
+{
+  "date": "2026-11-13",
+  "timezone": "Asia/Shanghai",
+  "purpose": "archive previously deployed account confirmation and workshop start fixes",
+  "productionReadOnly": true,
+  "productionWrites": 0,
+  "deploymentPerformed": false,
+  "functions": [
+    {
+      "objectId": "bTjPEnHYWF",
+      "name": "ksfWorkshopAccount",
+      "path": "/ksfWorkshopAccount",
+      "sha256": "beb24469e58947b8e80ffc4b10663ea2f86f47506f2b666c97dc015175a6fa51",
+      "matchesReleased": true
+    },
+    {
+      "objectId": "yKpUay6RDG",
+      "name": "ksfStartWorkshopSession",
+      "path": "/ksfStartWorkshopSession",
+      "sha256": "1db6ea3a59edb6ea842b3927ed67c3874f1b4fae58b6dfc816eb8816e782a0a4",
+      "matchesReleased": true
+    }
+  ],
+  "localTests": { "passed": 60, "failed": 0 },
+  "functionsBuiltAndSyntaxChecked": 34,
+  "schemaChanged": false,
+  "permissionsChanged": false,
+  "concurrentIdentityIsolationVerified": false
+}

+ 72 - 0
scripts/deploy-account-session-identity.mjs

@@ -0,0 +1,72 @@
+import fs from 'node:fs';
+import path from 'node:path';
+import crypto from 'node:crypto';
+import {fileURLToPath} from 'node:url';
+const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
+const hash = value => crypto.createHash('sha256').update(value).digest('hex');
+const block = text => {
+  const start = text.indexOf('  async function resolve(ownerId, key, account');
+  const end = text.indexOf('  const originalSave =', start);
+  if (start < 0 || end < 0) throw Error('SESSION_IDENTITY_BLOCK_MISSING');
+  return text.slice(start, end).replaceAll('\r\n', '\n');
+};
+const endpoint = {id: 'yKpUay6RDG', name: 'ksfStartWorkshopSession',
+  beforeSha256: 'c3791b03e1183bdb1373569be0c0ebf99fe4c3b22e4261618bacb379278900c0',
+  firstFixSha256: 'd843f6380d9b871f89bbaff8d687c5ec91493d4fc859bec263b12f34628372f3',
+  afterSha256: '1db6ea3a59edb6ea842b3927ed67c3874f1b4fae58b6dfc816eb8816e782a0a4'};
+export function sessionIdentityFunction(code) {
+  const previous = fs.readFileSync(path.join(root, 'deploy/account-binding-short-links-20261112/previous-account-binding.mjs'));
+  if (hash(previous) !== 'd02811b864e8cc75e5e061c9120ac769c09af8cb3b421e9c7757c723841dc2b4') throw Error('SESSION_IDENTITY_BASELINE_CHANGED');
+  const firstFix = fs.readFileSync(path.join(root, 'deploy/account-binding-short-links-20261112/session-identity-first-fix.txt'), 'utf8');
+  if (hash(firstFix) !== 'c5a99c34408b269828ff3c496b6fc22111df4d05ccefd2776dbdd237114957e9') throw Error('SESSION_IDENTITY_BASELINE_CHANGED');
+  const candidates = [block(previous.toString('utf8')), firstFix];
+  const matches = candidates.filter(candidate => code.split(candidate).length === 2);
+  if (matches.length !== 1) throw Error('SESSION_IDENTITY_BLOCK_MISMATCH');
+  const before = matches[0];
+  const next = block(fs.readFileSync(path.join(root, 'server/src/account-binding.mjs'), 'utf8'));
+  const patched = code.replace(before, next);
+  new Function(patched);
+  return patched;
+}
+export async function deployAccountSessionIdentity({request, apply = false, outDir = path.join(root, 'work/chat-single-reset-20261112')}) {
+  const row = await request('GET', '/classes/Function/' + endpoint.id);
+  if (row.name !== endpoint.name || row.path !== '/' + endpoint.name) throw Error('SESSION_IDENTITY_ENDPOINT_MISMATCH');
+  if (hash(row.code) === endpoint.afterSha256) return {ok: true, applied: false, alreadyApplied: true, endpoint: endpoint.name, afterSha256: endpoint.afterSha256};
+  if (![endpoint.beforeSha256,endpoint.firstFixSha256].includes(hash(row.code))) throw Error('UNKNOWN_SESSION_IDENTITY_FUNCTION_VERSION');
+  const code = sessionIdentityFunction(row.code);
+  if (hash(code) !== endpoint.afterSha256) throw Error('SESSION_IDENTITY_PAYLOAD_CHANGED');
+  const receipt = {ok: true, applied: false, endpoint: endpoint.name, objectId: endpoint.id, beforeSha256: hash(row.code), afterSha256: hash(code), schemaChanges: 0, permissionsChanged: false};
+  fs.mkdirSync(outDir, {recursive: true});
+  fs.writeFileSync(path.join(outDir, 'session-identity-deploy-plan.json'), JSON.stringify(receipt, null, 2));
+  if (!apply) return receipt;
+  const backup = path.join(outDir, 'session-identity-function-before-' + crypto.randomUUID() + '.private.json');
+  fs.writeFileSync(backup, JSON.stringify(row, null, 2), {flag: 'wx', mode: 0o600});
+  const current = await request('GET', '/classes/Function/' + endpoint.id);
+  if (current.code !== row.code || JSON.stringify(current.ACL) !== JSON.stringify(row.ACL)) throw Error('CONCURRENT_SESSION_IDENTITY_CHANGE');
+  let written = false;
+  try {
+    await request('PUT', '/classes/Function/' + endpoint.id, {code}); written = true;
+    const read = await request('GET', '/classes/Function/' + endpoint.id);
+    if (read.code !== code || read.name !== row.name || read.path !== row.path || JSON.stringify(read.ACL) !== JSON.stringify(row.ACL)) throw Error('SESSION_IDENTITY_READBACK_MISMATCH');
+    receipt.applied = true; receipt.readbackVerified = true; receipt.backup = backup;
+    fs.writeFileSync(path.join(outDir, 'session-identity-deploy-receipt.json'), JSON.stringify(receipt, null, 2));
+    return receipt;
+  } catch (error) {
+    receipt.ok = false; receipt.error = /^[A-Z_]+$/.test(error.message) ? error.message : 'SESSION_IDENTITY_UPDATE_FAILED';
+    if (written) {
+      const read = await request('GET', '/classes/Function/' + endpoint.id);
+      if (read.code === code) {
+        await request('PUT', '/classes/Function/' + endpoint.id, {code: row.code});
+        receipt.rolledBack = (await request('GET', '/classes/Function/' + endpoint.id)).code === row.code;
+      }
+    }
+    fs.writeFileSync(path.join(outDir, 'session-identity-deploy-failure.json'), JSON.stringify(receipt, null, 2));
+    throw Object.assign(Error(receipt.error), {receipt});
+  }
+}
+if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
+  if (!['plan', 'apply'].includes(process.argv[2]) || process.argv.length !== 3) throw Error('USAGE_PLAN_OR_APPLY');
+  const {request} = await import('../server/scripts/platform-client.mjs');
+  try { console.log(JSON.stringify(await deployAccountSessionIdentity({request, apply: process.argv[2] === 'apply'}))); }
+  catch (error) { console.error(JSON.stringify(error.receipt || {ok: false, error: /^[A-Z_]+$/.test(error.message) ? error.message : 'SESSION_IDENTITY_DEPLOY_FAILED'})); process.exitCode = 1; }
+}

+ 63 - 0
scripts/deploy-account-short-links.mjs

@@ -0,0 +1,63 @@
+import fs from 'node:fs';
+import path from 'node:path';
+import crypto from 'node:crypto';
+import {fileURLToPath} from 'node:url';
+const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
+const hash = value => crypto.createHash('sha256').update(value).digest('hex');
+const normalize = value => value.replaceAll('\r\n', '\n').replace(/^import .*;\n/gm, '').replace(/^export /gm, '').trim();
+const endpoint = {id: 'bTjPEnHYWF', name: 'ksfWorkshopAccount', beforeSha256: 'c9709c1a30e36011a69adc712d1fbecb824b5c63c6c2dbf3154d6badbe5d65d6', afterSha256: 'beb24469e58947b8e80ffc4b10663ea2f86f47506f2b666c97dc015175a6fa51'};
+export function shortLinkFunction(code) {
+  const before = fs.readFileSync(path.join(root, 'deploy/account-binding-short-links-20261112/previous-account-binding.mjs'));
+  if (hash(before) !== 'd02811b864e8cc75e5e061c9120ac769c09af8cb3b421e9c7757c723841dc2b4') throw Error('SHORT_LINK_BASELINE_CHANGED');
+  const oldModule = normalize(before.toString('utf8'));
+  const released = fs.readFileSync(path.join(root, 'deploy/account-binding-short-links-20261112/released-account-binding.mjs'), 'utf8');
+  if (hash(released) !== 'fcfefc23a3a0f3dc25ece94723d011f3457baac8dbee961b7f24c451d38656a2') throw Error('SHORT_LINK_RELEASE_CHANGED');
+  const nextModule = normalize(released);
+  if (code.split(oldModule).length !== 2) throw Error('SHORT_LINK_MODULE_MISMATCH');
+  const result = code.replace(oldModule, nextModule);
+  new Function(result);
+  return result;
+}
+export async function deployAccountShortLinks({request, apply = false, outDir = path.join(root, 'work/chat-single-reset-20261112')}) {
+  const row = await request('GET', '/classes/Function/' + endpoint.id);
+  if (row.name !== endpoint.name || row.path !== '/' + endpoint.name) throw Error('SHORT_LINK_ENDPOINT_MISMATCH');
+  if (hash(row.code) === endpoint.afterSha256) return {ok: true, applied: false, alreadyApplied: true, endpoint: endpoint.name, objectId: endpoint.id, afterSha256: endpoint.afterSha256, schemaChanges: 0, permissionsChanged: false};
+  if (hash(row.code) !== endpoint.beforeSha256) throw Error('UNKNOWN_SHORT_LINK_FUNCTION_VERSION');
+  const code = shortLinkFunction(row.code);
+  if (hash(code) !== endpoint.afterSha256) throw Error('SHORT_LINK_PAYLOAD_CHANGED');
+  const receipt = {ok: true, applied: false, endpoint: endpoint.name, objectId: endpoint.id, beforeSha256: hash(row.code), afterSha256: hash(code), schemaChanges: 0, permissionsChanged: false};
+  fs.mkdirSync(outDir, {recursive: true});
+  fs.writeFileSync(path.join(outDir, 'short-link-deploy-plan.json'), JSON.stringify(receipt, null, 2));
+  if (!apply) return receipt;
+  const backup = path.join(outDir, 'short-link-function-before-' + crypto.randomUUID() + '.private.json');
+  fs.writeFileSync(backup, JSON.stringify(row, null, 2), {flag: 'wx', mode: 0o600});
+  const current = await request('GET', '/classes/Function/' + endpoint.id);
+  if (current.code !== row.code || JSON.stringify(current.ACL) !== JSON.stringify(row.ACL)) throw Error('CONCURRENT_SHORT_LINK_CHANGE');
+  let written = false;
+  try {
+    await request('PUT', '/classes/Function/' + endpoint.id, {code}); written = true;
+    const read = await request('GET', '/classes/Function/' + endpoint.id);
+    if (read.code !== code || read.name !== row.name || read.path !== row.path || JSON.stringify(read.ACL) !== JSON.stringify(row.ACL)) throw Error('SHORT_LINK_READBACK_MISMATCH');
+    receipt.applied = true; receipt.readbackVerified = true; receipt.backup = backup;
+    fs.writeFileSync(path.join(outDir, 'short-link-deploy-receipt.json'), JSON.stringify(receipt, null, 2));
+    return receipt;
+  } catch (error) {
+    receipt.ok = false;
+    receipt.error = /^[A-Z_]+$/.test(error.message) ? error.message : 'SHORT_LINK_UPDATE_FAILED';
+    if (written) {
+      const read = await request('GET', '/classes/Function/' + endpoint.id);
+      if (read.code === code) {
+        await request('PUT', '/classes/Function/' + endpoint.id, {code: row.code});
+        receipt.rolledBack = (await request('GET', '/classes/Function/' + endpoint.id)).code === row.code;
+      }
+    }
+    fs.writeFileSync(path.join(outDir, 'short-link-deploy-failure.json'), JSON.stringify(receipt, null, 2));
+    throw Object.assign(Error(receipt.error), {receipt});
+  }
+}
+if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
+  if (!['plan', 'apply'].includes(process.argv[2]) || process.argv.length !== 3) throw Error('USAGE_PLAN_OR_APPLY');
+  const {request} = await import('../server/scripts/platform-client.mjs');
+  try { console.log(JSON.stringify(await deployAccountShortLinks({request, apply: process.argv[2] === 'apply'}))); }
+  catch (error) { console.error(JSON.stringify(error.receipt || {ok: false, error: /^[A-Z_]+$/.test(error.message) ? error.message : 'SHORT_LINK_DEPLOY_FAILED'})); process.exitCode = 1; }
+}

+ 30 - 6
server/src/account-binding.mjs

@@ -4,6 +4,7 @@ import { WORKSHOP_ROSTER } from '../../src/core/workshop-roster.js';
 export function attachAccountBindingHandlers(app, { store, settings, roster = WORKSHOP_ROSTER, fetchImpl = fetch, now = Date.now }) {
   const { fail, need, hash, canonical } = app;
   const schema = 'ksf.workshop-account-binding.v1';
+  const linkSchema = 'ksf.workshop-account-link-reference.v1';
   const companyId = roster.companyId;
   const exact = (p, keys) => { if (!p || typeof p !== 'object' || Array.isArray(p) || Object.keys(p).some(k => !keys.includes(k))) fail('VALIDATION_FAILED', '身份请求包含未允许字段'); };
   const profileKey = key => { if (!/^participant-(?:account:[a-f0-9]{64}|registration:[a-f0-9-]{36})$/.test(key || '')) fail('VALIDATION_FAILED', '员工档案键无效'); return key; };
@@ -30,6 +31,17 @@ export function attachAccountBindingHandlers(app, { store, settings, roster = WO
     return b;
   };
   const summary = b => ({ schema, status: 'bound', memberKey: b.memberKey, username: b.username, displayName: b.displayName, companyId, verifiedAt: b.verifiedAt, source: b.source });
+  async function confirmationTicket(value) {
+    // Keep signed payloads out of chat, whose long-string filters can truncate them.
+    // A reference retains 132 bits of the existing HMAC and resolves only server-side.
+    if (typeof value === 'string' && /^ksfl_[A-Za-z0-9_-]{22}$/.test(value)) {
+      const row = await read(store, 'workshop-account-link:' + hash(value));
+      if (row?.value?.schema !== linkSchema || row.value.expiresAt <= now() || typeof row.value.ticket !== 'string') fail('ACCOUNT_LINK_EXPIRED', '绑定链接已失效,请回对话重新获取');
+      return app.readReceipt(row.value.ticket);
+    }
+    // Previously issued signed links remain valid until their original expiry.
+    return app.readReceipt(value);
+  }
 
   // Called only by the HTTP adapter for confirmation, never from model-supplied params.
   app.authenticateWorkshopPlatform = async headers => {
@@ -50,7 +62,7 @@ export function attachAccountBindingHandlers(app, { store, settings, roster = WO
     if (p.action === 'confirm') {
       exact(p, ['requestId', 'action', 'ticket']);
       if (actor?.kind !== 'workshop-platform') fail('AUTH_REQUIRED', '必须由本人登录确认绑定');
-      const ticket = app.readReceipt(p.ticket);
+      const ticket = await confirmationTicket(p.ticket);
       if (ticket.type !== 'workshop-account-link' || ticket.expiresAt <= now()) fail('ACCOUNT_LINK_EXPIRED', '绑定链接已过期,请回对话重新获取');
       if (ticket.username !== actor.username) fail('ACCOUNT_BINDING_CONFLICT', '当前登录账号与对话中确认的账号不一致');
       return store.transaction(async s => {
@@ -92,22 +104,34 @@ export function attachAccountBindingHandlers(app, { store, settings, roster = WO
       const value = { ...v, pending };
       if (row) await s.update('KsfRuntimeConfig', row.objectId, { value }); else await s.create('KsfRuntimeConfig', { key: alias, value });
       const ticket = app.signedReceipt({ type: 'workshop-account-link', aliasKey: alias, ownerId: actor.id, profileKey: key, ...pending });
-      return { schema, status: 'awaiting_account_confirmation', username: p.username, expiresAt: new Date(pending.expiresAt).toISOString(), confirmationUrl: settings.functionsUrl + '?path=/ksfWorkshopAccount#ticket=' + encodeURIComponent(ticket) };
+      const signature = ticket.split('.').at(-1);
+      if (!/^[A-Za-z0-9_-]{43}$/.test(signature || '')) fail('ACCOUNT_LINK_UNAVAILABLE', '暂时无法生成绑定链接');
+      const reference = 'ksfl_' + signature.slice(0, 22), linkKey = 'workshop-account-link:' + hash(reference);
+      await s.lock(linkKey);
+      const prior = await read(s, linkKey, true);
+      if (prior && (prior.value?.schema !== linkSchema || prior.value.ticket !== ticket)) fail('ACCOUNT_BINDING_CONFLICT', '绑定链接冲突,请重新获取');
+      if (!prior) await s.create('KsfRuntimeConfig', { key: linkKey, value: { schema: linkSchema, expiresAt: pending.expiresAt, ticket } });
+      return { schema, status: 'awaiting_account_confirmation', username: p.username, expiresAt: new Date(pending.expiresAt).toISOString(), confirmationUrl: settings.functionsUrl + '?path=/ksfWorkshopAccount#ticket=' + encodeURIComponent(reference) };
     });
   };
-  async function resolve(ownerId, key, account) {
+  async function resolve(ownerId, key, account, { allowMissingTenant = false } = {}) {
     const row = await read(store, aliasKey(ownerId, key));
     if (!row) fail('ACCOUNT_BINDING_REQUIRED', '请先完成本人账号绑定,再保存成果');
     const b = bindingOf(checked(row.value, ownerId, key));
-    if (account?.platform !== 'fmode' || account.tenantId !== companyId || account.accountName !== b.username || ![null,b.username,b.platformUserId].includes(account.accountId ?? null)) fail('ACCOUNT_BINDING_CONFLICT', '提交身份与已绑定账号不一致');
+    if (account?.platform !== 'fmode' || (account.tenantId !== companyId && !(allowMissingTenant && account.tenantId == null)) || account.accountName !== b.username || ![null,b.username,b.platformUserId].includes(account.accountId ?? null)) fail('ACCOUNT_BINDING_CONFLICT', '提交身份与已绑定账号不一致');
     return b;
   }
   const originalStart = app.handlers.ksfStartWorkshopSession;
   app.handlers.ksfStartWorkshopSession = async (p, actor) => {
     if (p.participant?.account?.platform !== 'fmode') return originalStart(p, actor);
     need(actor, 'ingest:write');
-    const b = await resolve(actor.id, p.participant.key, p.participant.account);
-    const result = await originalStart(p, actor);
+    const b = await resolve(actor.id, p.participant.key, p.participant.account, { allowMissingTenant: true });
+    // First-use profiles can predate platform confirmation. Fill absent identity
+    // only from its verified immutable alias, without changing the profile key.
+    const account = p.participant.account.tenantId == null
+      ? { ...p.participant.account, tenantId: b.companyId, accountId: p.participant.account.accountId ?? b.platformUserId }
+      : p.participant.account;
+    const result = await originalStart({ ...p, participant: { ...p.participant, account } }, actor);
     // The alias is authoritative and immutable; no need to rewrite historical session keys.
     return { ...result, memberKey: b.memberKey, accountBinding: summary(b) };
   };

+ 64 - 1
server/test/account-binding.test.mjs

@@ -27,7 +27,7 @@ function fixture(){
  const account=p=>call('ksfWorkshopAccount',{requestId:crypto.randomUUID(),...p});
  const prepare=(profile=key,username='guopeng')=>account({action:'prepare',participantProfileKey:profile,username,displayName:'果鹏'});
  const confirm=async link=>call('ksfWorkshopAccount',{action:'confirm',ticket:new URL(link.confirmationUrl).hash.slice(8)},await app.authenticateWorkshopPlatform({'X-Fmode-Session-Token':'fixture-session'}));
- const start=(profile=key,workshop='topic-one')=>call('ksfStartWorkshopSession',{requestId:crypto.randomUUID(),workshopKey:workshop,participant:{key:profile,name:'果鹏',identityBasis:'existing_record',account:{platform:'fmode',tenantId:'FKieRbIoGt',accountId:current.username,accountName:current.username}},agent:{key:'agent-account:'+'c'.repeat(64),name:'助手',identityBasis:'runtime_config',role:'workshop',runtimePlatform:'hermes',channel:'matrix',account:{platform:'matrix',accountId:'fixture-agent'}},conversation:{key:'fixture-room',channel:'matrix'},dataMode:'live'});
+ const start=(profile=key,workshop='topic-one',identity={})=>call('ksfStartWorkshopSession',{requestId:crypto.randomUUID(),workshopKey:workshop,participant:{key:profile,name:'果鹏',identityBasis:'existing_record',account:{platform:'fmode',tenantId:'FKieRbIoGt',accountId:current.username,accountName:current.username,...identity}},agent:{key:'agent-account:'+'c'.repeat(64),name:'助手',identityBasis:'runtime_config',role:'workshop',runtimePlatform:'hermes',channel:'matrix',account:{platform:'matrix',accountId:'fixture-agent'}},conversation:{key:'fixture-room',channel:'matrix'},dataMode:'live'});
  return{store,app,call,account,prepare,confirm,start,setUser:user=>current=user,advance:ms=>clock+=ms};
 }
 test('首次绑定必须本人平台会话,未绑定提交被拦截且不创建成员或报告',async()=>{
@@ -68,3 +68,66 @@ test('过期与替换链接失效,绑定重复确认幂等且服务隔离',asy
  const status=await f.call('ksfWorkshopAccount',{action:'status',participantProfileKey:key},{...service,id:'another-service'});assert.equal(status.status,'unbound');
  const g=fixture(),expired=await g.prepare();g.advance(31*60000);await assert.rejects(()=>g.confirm(expired),{code:'ACCOUNT_LINK_EXPIRED'});
 });
+
+test('首次登记缺企业和平台编号时,从本人已确认别名补齐会话,不换员工键',async()=>{
+ const f=fixture(),profile='participant-registration:098130da-48d3-4d1d-8a2e-a547a6693acb';
+ await assert.rejects(()=>f.start(profile,'first-use',{tenantId:null,accountId:null}),{code:'ACCOUNT_BINDING_REQUIRED'});
+ const bound=await f.confirm(await f.prepare(profile));
+ const first=await f.start(profile,'first-use',{tenantId:null,accountId:null});
+ const again=await f.start(profile,'first-use',{tenantId:null,accountId:null});
+ assert.equal(again.sessionId,first.sessionId);assert.equal(first.memberKey,bound.memberKey);
+ const session=await f.store.get('KfSession',first.sessionId),a=session.meta.attribution;
+ assert.equal(a.accountProfileKey,profile);assert.equal(a.participantAccount.tenantId,'FKieRbIoGt');
+ assert.equal(a.participantAccount.accountId,audit.results.find(x=>x.username==='guopeng').userId);
+ assert.equal(a.participantAccount.accountName,'guopeng');
+ const board=await f.call('ksfGetPublicBoard',{},null);
+ assert.equal(board.session[0].memberKey,bound.memberKey);
+ assert.equal((await f.store.list('KfSession')).length,1);assert.equal((await f.store.list('KfArtifact')).length,0);
+});
+
+test('已绑定别名仍拒绝显式跨企业、其他账号和其他平台编号,不回填冲突值',async()=>{
+ const f=fixture();await f.confirm(await f.prepare());
+ for(const identity of [{tenantId:'other-company',accountId:null},{tenantId:null,accountName:'liuna',accountId:null},{tenantId:null,accountId:'other-user'}]){
+   await assert.rejects(()=>f.start(key,'conflict',identity),{code:'ACCOUNT_BINDING_CONFLICT'});
+ }
+ assert.equal((await f.store.list('KfSession')).length,0);
+ assert.equal((await f.account({action:'status',participantProfileKey:key})).username,'guopeng');
+});
+
+test('既有企业明确但平台编号为空的合法会话保持原归属字节,可重复复用',async()=>{
+ const f=fixture();await f.confirm(await f.prepare());
+ const first=await f.start(key,'legacy-account',{accountId:null});
+ const before=await f.store.get('KfSession',first.sessionId);
+ assert.equal(before.meta.attribution.participantAccount.accountId,null);
+ const again=await f.start(key,'legacy-account',{accountId:null});
+ assert.equal(again.sessionId,first.sessionId);assert.equal(again.reused,true);
+ assert.deepEqual(await f.store.get('KfSession',first.sessionId),before);
+});
+
+test('短确认入口不携带身份载荷;丢失、篡改引用及伪造服务确认不能绑定',async()=>{
+ const f=fixture(),prepared=await f.prepare(),url=new URL(prepared.confirmationUrl),reference=new URLSearchParams(url.hash.slice(1)).get('ticket');
+ assert.match(reference,/^ksfl_[A-Za-z0-9_-]{22}$/);
+ assert.ok(prepared.confirmationUrl.length<110);
+ assert.doesNotMatch(prepared.confirmationUrl,/guopeng|participant-|trusted-service|eyJ|\.\.\./);
+ const actor=await f.app.authenticateWorkshopPlatform({'X-Fmode-Session-Token':'fixture-session'});
+ const forged='ksfl_'+(reference[5]==='A'?'B':'A')+reference.slice(6);
+ await assert.rejects(()=>f.call('ksfWorkshopAccount',{action:'confirm',ticket:forged},actor),{code:'ACCOUNT_LINK_EXPIRED'});
+ await assert.rejects(()=>f.call('ksfWorkshopAccount',{action:'confirm',ticket:reference}),{code:'AUTH_REQUIRED'});
+ assert.equal((await f.account({action:'status',participantProfileKey:key})).status,'unbound');
+ assert.equal((await f.store.list('KfSession')).length,0);assert.equal((await f.store.list('KfArtifact')).length,0);
+ assert.equal((await f.confirm(prepared)).status,'bound');
+});
+
+test('既有完整签名链接继续核验;服务端短引用的签名载荷被改动时拒绝确认',async()=>{
+ const f=fixture(),prepared=await f.prepare();
+ const links=(await f.store.list('KsfRuntimeConfig')).filter(row=>row.key.startsWith('workshop-account-link:'));
+ assert.equal(links.length,1);
+ const signed=links[0].value.ticket;
+ assert.equal((await f.confirm({confirmationUrl:'https://kangshifu.fmode.cn/api/functions?path=/ksfWorkshopAccount#ticket='+encodeURIComponent(signed)})).status,'bound');
+ const g=fixture(),next=await g.prepare(),row=(await g.store.list('KsfRuntimeConfig')).find(row=>row.key.startsWith('workshop-account-link:'));
+ const [body,signature]=row.value.ticket.split('.'),payload=JSON.parse(Buffer.from(body,'base64url').toString('utf8'));
+ payload.username='liuna';
+ await g.store.update('KsfRuntimeConfig',row.objectId,{value:{...row.value,ticket:Buffer.from(JSON.stringify(payload)).toString('base64url')+'.'+signature}});
+ await assert.rejects(()=>g.confirm(next),{code:'REPORT_NOT_CONFIRMED'});
+ assert.equal((await g.account({action:'status',participantProfileKey:key})).status,'unbound');
+});

+ 23 - 0
server/test/account-session-identity-deploy.test.mjs

@@ -0,0 +1,23 @@
+import test from 'node:test';
+import assert from 'node:assert/strict';
+import fs from 'node:fs';
+import {sessionIdentityFunction, deployAccountSessionIdentity} from '../../scripts/deploy-account-session-identity.mjs';
+const prior = fs.readFileSync(new URL('../../deploy/account-binding-short-links-20261112/previous-account-binding.mjs', import.meta.url), 'utf8').replaceAll('\r\n', '\n');
+
+test('开课补丁仅替换绑定解析与开课块,保存、账号确认和外围代码保持原字节', () => {
+  const start=prior.indexOf('  async function resolve(ownerId, key, account'), end=prior.indexOf('  const originalSave =',start);
+  const prefix=prior.slice(0,start),suffix=prior.slice(end);
+  const output=sessionIdentityFunction(prior.replace(/^import .*;\n/gm,'').replace(/^export /gm,''));
+  assert.ok(output.startsWith(prefix.replace(/^import .*;\n/gm,'').replace(/^export /gm,'')));
+  assert.ok(output.endsWith(suffix));
+  assert.ok(output.includes('allowMissingTenant: true'));
+  assert.ok(output.includes('accountId: p.participant.account.accountId ?? b.platformUserId'));
+  assert.throws(()=>sessionIdentityFunction(''),/BLOCK_MISMATCH/);
+});
+
+test('未知线上版本阻止开课函数补丁,不发生远程写入', async () => {
+  const calls=[];
+  const request=async(method,route)=>{calls.push({method,route});return{name:'ksfStartWorkshopSession',path:'/ksfStartWorkshopSession',code:'unknown-version'};};
+  await assert.rejects(()=>deployAccountSessionIdentity({request,apply:true}),/UNKNOWN_SESSION_IDENTITY_FUNCTION_VERSION/);
+  assert.deepEqual(calls.map(row=>row.method),['GET']);
+});

+ 23 - 0
server/test/account-short-links-deploy.test.mjs

@@ -0,0 +1,23 @@
+import test from 'node:test';
+import assert from 'node:assert/strict';
+import fs from 'node:fs';
+import {shortLinkFunction, deployAccountShortLinks} from '../../scripts/deploy-account-short-links.mjs';
+
+const prior = fs.readFileSync(new URL('../../deploy/account-binding-short-links-20261112/previous-account-binding.mjs', import.meta.url), 'utf8')
+  .replaceAll('\r\n', '\n').replace(/^import .*;\n/gm, '').replace(/^export /gm, '').trim();
+
+test('短链接部署精确替换账号模块,保留外围生产代码', () => {
+  const prefix = 'const untouchedBefore = "before";\n', suffix = '\nconst untouchedAfter = "after";';
+  const patched = shortLinkFunction(prefix + prior + suffix);
+  assert.ok(patched.startsWith(prefix)); assert.ok(patched.endsWith(suffix));
+  assert.ok(patched.includes('confirmationTicket(p.ticket)'));
+  assert.ok(patched.includes("reference = 'ksfl_'"));
+  assert.throws(() => shortLinkFunction(prior + '\n' + prior), /MODULE_MISMATCH/);
+});
+
+test('线上账号函数存在未知改动时,任何远程写入前停止', async () => {
+  const calls = [];
+  const request = async (method, route) => { calls.push({method, route}); return {objectId: 'bTjPEnHYWF', name: 'ksfWorkshopAccount', path: '/ksfWorkshopAccount', code: prior}; };
+  await assert.rejects(() => deployAccountShortLinks({request, apply: true}), /UNKNOWN_SHORT_LINK_FUNCTION_VERSION/);
+  assert.deepEqual(calls.map(call => call.method), ['GET']);
+});