|
@@ -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 } } };
|
|
|
|
|
+ };
|
|
|
|
|
+}
|