From 5afe021b56742cdf1f33102c2a5bfcadf9e9d5a2 Mon Sep 17 00:00:00 2001 From: freedakgmail Date: Sat, 13 Jun 2026 19:18:17 +0800 Subject: [PATCH] =?UTF-8?q?=E5=A4=A7=E6=94=B9=E5=8A=A8=EF=BC=9A=E5=85=A8?= =?UTF-8?q?=E9=83=A8=E4=BA=BA=E5=91=98=E7=94=A8ID=E5=85=B3=E8=81=94?= =?UTF-8?q?=EF=BC=88JWT=E5=B8=A6uid=20+=20audit=20actor=5Fid=20+=20assesso?= =?UTF-8?q?rId=E6=94=B9=E7=94=A8=E6=88=B7ID=20+=20=E7=9C=8B=E6=9D=BF?= =?UTF-8?q?=E6=8C=89ID=E5=8C=B9=E9=85=8D=20+=20=E5=8E=86=E5=8F=B2=E5=9B=9E?= =?UTF-8?q?=E5=A1=AB=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - JWT 载荷增加 uid;登录返回 id;前端持久化 uid 并在变更请求中携带 userId - 操作人服务端解析(优先JWT.uid,回退body.userId),审计写入 actor_id + 当时显示名 - audit_logs 增加 actor_id 列;持久化与加载带 actorId - assessments.assessorId 改存用户ID,列表/详情按ID解析显示名(assessorName) - 看板待办「分给我的」改为按 userId 匹配;发起人显示真实姓名 - 审批线指派按 salesId(用户ID) 计算 - scripts/backfill-actor-ids.sql 回填历史(旧账号名→当前用户ID),本地+生产已执行 --- migrations/1730000026000_audit_actor.cjs | 14 +++ scripts/backfill-actor-ids.sql | 37 ++++++++ src/auth/index.ts | 6 +- src/persistence/pg.ts | 12 ++- src/persistence/users.ts | 10 +++ src/server/index.ts | 110 +++++++++++++++++------ web/src/api/client.ts | 25 +++++- web/src/pages/Dashboard.tsx | 13 +-- web/src/pages/NewAssessment.tsx | 6 +- web/src/stores/authStore.ts | 8 +- 10 files changed, 194 insertions(+), 47 deletions(-) create mode 100644 migrations/1730000026000_audit_actor.cjs create mode 100644 scripts/backfill-actor-ids.sql diff --git a/migrations/1730000026000_audit_actor.cjs b/migrations/1730000026000_audit_actor.cjs new file mode 100644 index 0000000..3949390 --- /dev/null +++ b/migrations/1730000026000_audit_actor.cjs @@ -0,0 +1,14 @@ +/* eslint-disable */ +/** + * 审计日志增加 actor_id(操作人用户 ID 关联)。username 保留为当时显示名快照。 + */ + +exports.up = (pgm) => { + pgm.sql(`ALTER TABLE audit_logs ADD COLUMN IF NOT EXISTS actor_id TEXT;`); + pgm.sql(`CREATE INDEX IF NOT EXISTS idx_audit_actor ON audit_logs(actor_id);`); +}; + +exports.down = (pgm) => { + pgm.sql(`DROP INDEX IF EXISTS idx_audit_actor;`); + pgm.sql(`ALTER TABLE audit_logs DROP COLUMN IF EXISTS actor_id;`); +}; diff --git a/scripts/backfill-actor-ids.sql b/scripts/backfill-actor-ids.sql new file mode 100644 index 0000000..8e9af03 --- /dev/null +++ b/scripts/backfill-actor-ids.sql @@ -0,0 +1,37 @@ +-- 历史数据回填:把按"人名字符串"关联的记录改为按用户 ID 关联。 +-- 处理旧账号名→当前账号名的改名映射,再解析为用户 ID。可重复执行(幂等)。 + +WITH alias(oldname, newname) AS (VALUES + ('销售账号','张伟'), ('销售二','王芳'), ('销售三','李娜'), + ('风控账号','刘洋'), ('风控二','陈静'), + ('管理账号','赵磊'), ('管理二','孙莉'), + ('系统账号','周强') +) +UPDATE assessments a +SET assessment = jsonb_set(a.assessment, '{assessorId}', to_jsonb(u.id)) +FROM alias al JOIN users u ON u.username = al.newname +WHERE a.assessment->>'assessorId' = al.oldname; + +-- assessorId 已是当前用户名 → 也转为 ID +UPDATE assessments a +SET assessment = jsonb_set(a.assessment, '{assessorId}', to_jsonb(u.id)) +FROM users u +WHERE a.assessment->>'assessorId' = u.username; + +-- 审计:按旧名映射回填 actor_id,并把 username 刷新为当前账号名 +WITH alias(oldname, newname) AS (VALUES + ('销售账号','张伟'), ('销售二','王芳'), ('销售三','李娜'), + ('风控账号','刘洋'), ('风控二','陈静'), + ('管理账号','赵磊'), ('管理二','孙莉'), + ('系统账号','周强') +) +UPDATE audit_logs al2 +SET actor_id = u.id, username = u.username +FROM alias al JOIN users u ON u.username = al.newname +WHERE al2.actor_id IS NULL AND al2.username = al.oldname; + +-- 审计:username 已是当前账号名 → 回填 actor_id +UPDATE audit_logs al2 +SET actor_id = u.id +FROM users u +WHERE al2.actor_id IS NULL AND al2.username = u.username; diff --git a/src/auth/index.ts b/src/auth/index.ts index 74a96a0..a1b2332 100644 --- a/src/auth/index.ts +++ b/src/auth/index.ts @@ -13,6 +13,8 @@ export type AuthRole = '商务/销售' | '风控' | '管理层' | '系统管理 export interface AuthPayload { username: string; role: AuthRole; + /** 用户 ID(稳定锚,用于记录关联)。旧 token 可能缺省。 */ + uid?: string; iat: number; exp: number; } @@ -48,9 +50,9 @@ function verify(token: string): AuthPayload | null { } /** 签发 JWT(登录成功后调用)。 */ -export function issueToken(username: string, role: AuthRole): string { +export function issueToken(username: string, role: AuthRole, uid?: string): string { const now = Math.floor(Date.now() / 1000); - return sign({ username, role, iat: now, exp: now + 86400 }); + return sign({ username, role, ...(uid !== undefined ? { uid } : {}), iat: now, exp: now + 86400 }); } /** 无需鉴权的公共路径(登录与健康检查)。 */ diff --git a/src/persistence/pg.ts b/src/persistence/pg.ts index c726f1c..cfd8294 100644 --- a/src/persistence/pg.ts +++ b/src/persistence/pg.ts @@ -35,7 +35,10 @@ export function poolOptionsFromEnv(): Pick< /** 操作记录条目(与服务端一致)。 */ export interface AuditLogEntry { readonly role: string; + /** 操作人显示名(当时快照)。 */ readonly username: string; + /** 操作人用户 ID(关联锚;历史记录可能缺省)。 */ + readonly actorId?: string; readonly action: string; readonly comment?: string; readonly timestamp: string; @@ -362,7 +365,7 @@ export async function loadWorkflowState(pool: Pool): Promise<{ } const a = await pool.query( - 'SELECT assessment_id, role, username, action, comment, ts FROM audit_logs ORDER BY id ASC', + 'SELECT assessment_id, role, username, actor_id, action, comment, ts FROM audit_logs ORDER BY id ASC', ); for (const r of a.rows as Array>) { const id = String(r.assessment_id); @@ -370,6 +373,7 @@ export async function loadWorkflowState(pool: Pool): Promise<{ list.push({ role: String(r.role), username: String(r.username), + ...(r.actor_id != null ? { actorId: String(r.actor_id) } : {}), action: String(r.action), ...(r.comment != null ? { comment: String(r.comment) } : {}), timestamp: toIso(r.ts), @@ -399,9 +403,9 @@ export async function persistStatus(pool: Pool, id: string, status: string): Pro /** 追加一条操作记录。 */ export async function persistAudit(pool: Pool, id: string, entry: AuditLogEntry): Promise { await pool.query( - `INSERT INTO audit_logs(assessment_id, role, username, action, comment, ts) - VALUES($1, $2, $3, $4, $5, $6)`, - [id, entry.role, entry.username, entry.action, entry.comment ?? null, entry.timestamp], + `INSERT INTO audit_logs(assessment_id, role, username, actor_id, action, comment, ts) + VALUES($1, $2, $3, $4, $5, $6, $7)`, + [id, entry.role, entry.username, entry.actorId ?? null, entry.action, entry.comment ?? null, entry.timestamp], ); } diff --git a/src/persistence/users.ts b/src/persistence/users.ts index 6374e99..37bbf76 100644 --- a/src/persistence/users.ts +++ b/src/persistence/users.ts @@ -55,6 +55,16 @@ export async function listUsers(pool: pg.Pool): Promise { return (res.rows as Array>).map(mapRow); } +/** 按 ID 取用户(不含密码)。 */ +export async function getUserById(pool: pg.Pool, id: string): Promise { + const res = await pool.query( + 'SELECT id, username, display_name, role, active, created_at FROM users WHERE id = $1', + [id], + ); + const r = (res.rows as Array>)[0]; + return r ? mapRow(r) : null; +} + /** 按用户名取用户(含密码哈希,仅供登录校验内部使用)。 */ export async function getUserByUsername( pool: pg.Pool, diff --git a/src/server/index.ts b/src/server/index.ts index 5fb6e3a..9510705 100644 --- a/src/server/index.ts +++ b/src/server/index.ts @@ -72,6 +72,7 @@ import { ensureSeedUsers, USER_ROLES, type UserRole, + getUserById, getApprovalConfig, saveApprovalConfig, ensureApprovalConfig, @@ -123,7 +124,7 @@ import { type SynthesisContext, } from '../llm/index.js'; import type { ResolvedDataPoint } from '../adapters/index.js'; -import { authMiddleware, requireRole, issueToken, type AuthRole } from '../auth/index.js'; +import { authMiddleware, requireRole, issueToken, type AuthRole, type AuthPayload } from '../auth/index.js'; import type { BusinessType, Industry } from '../domain/common.js'; import type { Indicator } from '../domain/index.js'; @@ -160,8 +161,8 @@ app.post('/api/auth/login', async (c) => { if (user === null || !user.active || !verifyPassword(password, user.passwordHash)) { return c.json({ error: '用户名或密码错误,或账号已停用' }, 401); } - const token = issueToken(user.username, user.role as AuthRole); - return c.json({ token, username: user.username, role: user.role, displayName: user.displayName }); + const token = issueToken(user.username, user.role as AuthRole, user.id); + return c.json({ token, id: user.id, username: user.username, role: user.role, displayName: user.displayName }); } // 无数据库(演示模式):按传入角色签发,兼容旧行为。 @@ -635,9 +636,11 @@ app.post('/api/assessments/run', async (c) => { // 由销售确认资料无误后点「申报」按钮才报送风控审核。 { setStatus(result.assessment.id, 'draft'); + const actC = await resolveActor(c, body.assessorId, undefined); const submitEntry: AuditLogEntry = { role: '商务/销售', - username: result.assessment.assessorId ?? '系统', + username: actC.name, + ...(actC.id !== null ? { actorId: actC.id } : {}), action: isEdit ? '编辑并重新评估(草稿待申报)' : '创建评估(草稿待申报)', timestamp: new Date().toISOString(), }; @@ -1538,6 +1541,36 @@ function setStatus(id: string, status: WorkflowStatus): void { workflowStatus.set(id, status); } +/** + * 解析操作人:优先 JWT(uid),否则用 body 传入的 userId;并解析为当前显示名(人名)。 + * 返回 { id, name },供审计 actor_id 与显示名快照使用。 + */ +async function resolveActor( + c: import('hono').Context, + bodyUserId?: string, + fallbackName?: string, +): Promise<{ id: string | null; name: string }> { + const payload = c.get('user') as AuthPayload | undefined; + const id = payload?.uid ?? bodyUserId ?? null; + let name = payload?.username ?? fallbackName ?? (id ?? '系统'); + if (pool !== null && id) { + const u = await getUserById(pool, id).catch(() => null); + if (u) name = u.username; + } + return { id, name }; +} + +/** 用户 ID → 用户名(人名) 映射,供把记录中的 ID 解析为显示名。 */ +async function userIdNameMap(): Promise> { + if (pool === null) return {}; + try { + const us = await listUsers(pool); + return Object.fromEntries(us.map((u) => [u.id, u.username])); + } catch { + return {}; + } +} + function addAuditLog(id: string, entry: AuditLogEntry): void { const existing = auditLogs.get(id) ?? []; auditLogs.set(id, [...existing, entry]); @@ -1601,7 +1634,12 @@ app.get('/api/assessments', async (c) => { ...(statusFilter !== undefined ? { status: statusFilter } : {}), ...(q !== undefined ? { q } : {}), }); - return c.json({ items, total, page, pageSize: size }); + const nameMap = await userIdNameMap(); + const items2 = items.map((it) => ({ + ...it, + assessorName: nameMap[String(it.assessorId)] ?? String(it.assessorId ?? ''), + })); + return c.json({ items: items2, total, page, pageSize: size }); } // 无 DB:内存过滤 + 分页(小数据量)。 @@ -1695,6 +1733,8 @@ app.get('/api/assessments/:id', async (c) => { } // 审批人指派(按审批线,软约束)。 const assignment = pool !== null ? await getAssignment(pool, id).catch(() => null) : null; + const assessorId = (record.assessment as unknown as { assessorId?: string }).assessorId; + const assessorName = assessorId !== undefined ? (await userIdNameMap())[assessorId] ?? assessorId : null; return c.json({ assessment: record.assessment, report: record.report, @@ -1710,6 +1750,7 @@ app.get('/api/assessments/:id', async (c) => { profitabilityInputs: profitabilityInputsById.get(id) ?? null, expiresAt, assignment, + assessorName, }); }); @@ -1756,10 +1797,12 @@ app.post('/api/assessments/:id/archive', async (c) => { return c.json({ error: '评估记录不存在' }, 404); } const body = await c.req - .json<{ archived?: boolean; user?: string }>() - .catch(() => ({}) as { archived?: boolean; user?: string }); + .json<{ archived?: boolean; user?: string; userId?: string }>() + .catch(() => ({}) as { archived?: boolean; user?: string; userId?: string }); const archived = body.archived !== false; // 缺省视为归档 - const username = body.user ?? '系统'; + const actA = await resolveActor(c, body.userId, body.user); + const username = actA.name; + const actorIdPart = actA.id !== null ? { actorId: actA.id } : {}; if (archived) { const current = getStatus(id) ?? 'pending_risk_review'; @@ -1770,6 +1813,7 @@ app.post('/api/assessments/:id/archive', async (c) => { const entry: AuditLogEntry = { role: '流程', username, + ...actorIdPart, action: '归档', timestamp: new Date().toISOString(), }; @@ -1789,6 +1833,7 @@ app.post('/api/assessments/:id/archive', async (c) => { const entry: AuditLogEntry = { role: '流程', username, + ...actorIdPart, action: '取消归档(重置为待风控审核)', timestamp: new Date().toISOString(), }; @@ -1869,11 +1914,12 @@ app.post('/api/assessments/:id/review', requireRole('风控'), async (c) => { if (record === undefined) { return c.json({ error: '评估记录不存在' }, 404); } - const body = await c.req.json<{ action: 'approve' | 'reject'; comment?: string; user: string }>(); + const body = await c.req.json<{ action: 'approve' | 'reject'; comment?: string; user?: string; userId?: string }>(); const current = getStatus(id) ?? 'pending_risk_review'; if (current !== 'pending_risk_review') { return c.json({ error: `当前状态为 ${current},无法审核` }, 400); } + const act = await resolveActor(c, body.userId, body.user); let newStatus: WorkflowStatus; let actionText: string; if (body.action === 'approve') { @@ -1918,7 +1964,8 @@ app.post('/api/assessments/:id/review', requireRole('风控'), async (c) => { setStatus(id, newStatus); const reviewEntry: AuditLogEntry = { role: '风控', - username: body.user, + username: act.name, + ...(act.id !== null ? { actorId: act.id } : {}), action: actionText, ...(body.comment !== undefined && body.comment !== '' ? { comment: body.comment } : {}), timestamp: new Date().toISOString(), @@ -1938,7 +1985,7 @@ app.post('/api/assessments/:id/approve', requireRole('管理层'), async (c) => if (record === undefined) { return c.json({ error: '评估记录不存在' }, 404); } - const body = await c.req.json<{ action: 'approve' | 'reject' | 'abandon'; comment?: string; user: string; rejectTo?: 'risk' | 'origin' }>(); + const body = await c.req.json<{ action: 'approve' | 'reject' | 'abandon'; comment?: string; user?: string; userId?: string; rejectTo?: 'risk' | 'origin' }>(); const current = getStatus(id) ?? 'pending_risk_review'; if (current !== 'risk_reviewed' && current !== 'pending_risk_review') { return c.json({ error: `当前状态为 ${current},无法审批` }, 400); @@ -1962,9 +2009,11 @@ app.post('/api/assessments/:id/approve', requireRole('管理层'), async (c) => actionText = '管理层驳回(退回风控复审)'; } setStatus(id, newStatus); + const act = await resolveActor(c, body.userId, body.user); const approveEntry: AuditLogEntry = { role: '管理层', - username: body.user, + username: act.name, + ...(act.id !== null ? { actorId: act.id } : {}), action: actionText, ...(body.comment !== undefined && body.comment !== '' ? { comment: body.comment } : {}), timestamp: new Date().toISOString(), @@ -1987,7 +2036,7 @@ app.post('/api/assessments/:id/override', requireRole('管理层'), async (c) => if (record === undefined) { return c.json({ error: '评估记录不存在' }, 404); } - const body = await c.req.json<{ status: WorkflowStatus; user: string; comment?: string }>(); + const body = await c.req.json<{ status: WorkflowStatus; user?: string; userId?: string; comment?: string }>(); const current = getStatus(id) ?? 'pending_risk_review'; if (current !== 'approved' && current !== 'abandoned') { return c.json({ error: '仅可对「已通过」或「已放弃」的项目直接调整状态' }, 400); @@ -2004,9 +2053,11 @@ app.post('/api/assessments/:id/override', requireRole('管理层'), async (c) => return c.json({ error: `非法目标状态:${body.status}` }, 400); } setStatus(id, body.status); + const actOv = await resolveActor(c, body.userId, body.user); const entry: AuditLogEntry = { role: '管理层', - username: body.user, + username: actOv.name, + ...(actOv.id !== null ? { actorId: actOv.id } : {}), action: `管理层直接调整状态(${STATUS_CN[current]} → ${STATUS_CN[body.status]})`, ...(body.comment !== undefined && body.comment !== '' ? { comment: body.comment } : {}), timestamp: new Date().toISOString(), @@ -2038,6 +2089,7 @@ app.put('/api/assessments/:id', async (c) => { region?: string; profitabilityInputs?: ProfitabilityInputs; user?: string; + userId?: string; }>(); // 更新评估记录字段 @@ -2161,9 +2213,11 @@ app.put('/api/assessments/:id', async (c) => { await flushStore(); // 审计留痕 + const actE = await resolveActor(c, body.userId, body.user); const editEntry: AuditLogEntry = { role: '商务/销售', - username: body.user ?? '销售', + username: actE.name, + ...(actE.id !== null ? { actorId: actE.id } : {}), action: '修改项目资料(驳回后调整)', timestamp: new Date().toISOString(), }; @@ -2185,7 +2239,7 @@ app.post('/api/assessments/:id/redline-verdict', requireRole('风控', '管理 if (record === undefined) { return c.json({ error: '评估记录不存在' }, 404); } - const body = await c.req.json<{ redlineId: string; status: '命中' | '未命中'; note?: string; user: string; role?: string; title?: string }>(); + const body = await c.req.json<{ redlineId: string; status: '命中' | '未命中'; note?: string; user?: string; userId?: string; role?: string; title?: string }>(); if (body.status !== '命中' && body.status !== '未命中') { return c.json({ error: '裁定状态须为「命中」或「未命中」' }, 400); } @@ -2232,9 +2286,11 @@ app.post('/api/assessments/:id/redline-verdict', requireRole('风控', '管理 store.put({ ...record, assessment: a, report: updatedReport, savedAt: new Date().toISOString() }); await flushStore(); + const actV = await resolveActor(c, body.userId, body.user); const entry: AuditLogEntry = { role: body.role ?? '风控', - username: body.user, + username: actV.name, + ...(actV.id !== null ? { actorId: actV.id } : {}), action: `人工裁定红线「${title}」→ ${body.status}`, ...(note !== '' ? { comment: note } : {}), timestamp: new Date().toISOString(), @@ -2252,12 +2308,11 @@ app.post('/api/assessments/:id/redline-verdict', requireRole('风控', '管理 * 按审批线为评估指派审批人(软约束,仅展示/过滤/审计)。 * 据提交销售解析审批线 → 指定风控/管理层;写入 assessment_assignments。 */ -async function assignApprovers(id: string, salesUsername: string): Promise { +async function assignApprovers(id: string, salesId: string | undefined): Promise { if (pool === null) return; const cfg = await getApprovalConfig(pool); if (!cfg.assignment.enabled) return; - const sales = await getUserByUsername(pool, salesUsername); - const { riskReviewerId, managerId } = resolveAssignees(cfg.assignment, sales?.id); + const { riskReviewerId, managerId } = resolveAssignees(cfg.assignment, salesId); const users = await listUsers(pool); const nameOf = (uid: string | null): string | null => { if (uid === null) return null; @@ -2280,15 +2335,17 @@ app.post('/api/assessments/:id/submit', async (c) => { if (record === undefined) { return c.json({ error: '评估记录不存在' }, 404); } - const body = await c.req.json<{ user: string }>(); + const body = await c.req.json<{ user?: string; userId?: string }>(); const current = getStatus(id) ?? 'pending_risk_review'; if (current !== 'draft') { return c.json({ error: `当前状态为 ${current},无法申报` }, 400); } + const actS = await resolveActor(c, body.userId, body.user); setStatus(id, 'pending_risk_review'); const submitEntry: AuditLogEntry = { role: '商务/销售', - username: body.user, + username: actS.name, + ...(actS.id !== null ? { actorId: actS.id } : {}), action: '申报(报送风控审核)', timestamp: new Date().toISOString(), }; @@ -2296,7 +2353,7 @@ app.post('/api/assessments/:id/submit', async (c) => { if (pool !== null) { await persistStatus(pool, id, 'pending_risk_review'); await persistAudit(pool, id, submitEntry); - await assignApprovers(id, body.user).catch(() => undefined); + await assignApprovers(id, actS.id ?? undefined).catch(() => undefined); } return c.json({ status: 'pending_risk_review' }); }); @@ -2308,15 +2365,17 @@ app.post('/api/assessments/:id/resubmit', async (c) => { if (record === undefined) { return c.json({ error: '评估记录不存在' }, 404); } - const body = await c.req.json<{ user: string }>(); + const body = await c.req.json<{ user?: string; userId?: string }>(); const current = getStatus(id) ?? 'pending_risk_review'; if (current !== 'rejected') { return c.json({ error: `当前状态为 ${current},无需重新提交` }, 400); } + const actR = await resolveActor(c, body.userId, body.user); setStatus(id, 'pending_risk_review'); const resubmitEntry: AuditLogEntry = { role: '商务/销售', - username: body.user, + username: actR.name, + ...(actR.id !== null ? { actorId: actR.id } : {}), action: '重新提交评估', timestamp: new Date().toISOString(), }; @@ -2324,6 +2383,7 @@ app.post('/api/assessments/:id/resubmit', async (c) => { if (pool !== null) { await persistStatus(pool, id, 'pending_risk_review'); await persistAudit(pool, id, resubmitEntry); + await assignApprovers(id, actR.id ?? undefined).catch(() => undefined); } return c.json({ status: 'pending_risk_review' }); }); diff --git a/web/src/api/client.ts b/web/src/api/client.ts index 679e201..e6ff864 100644 --- a/web/src/api/client.ts +++ b/web/src/api/client.ts @@ -24,6 +24,15 @@ function authHeader(): Record { } } +/** 当前登录用户 ID(用于在记录中以 ID 关联操作人;演示模式下作为 body 兜底)。 */ +export function currentUserId(): string | undefined { + try { + return localStorage.getItem('risk-agent-uid') ?? undefined; + } catch { + return undefined; + } +} + /** API 调用错误。 */ export class ApiError extends Error { constructor( @@ -118,6 +127,8 @@ export interface AssessmentListItem { readonly acceptability?: string; readonly createdAt: string; readonly assessorId: string; + /** 发起人显示名(按 assessorId 解析)。 */ + readonly assessorName?: string; readonly status: WorkflowStatus; readonly auditLog: AuditLogEntry[]; readonly archived?: boolean; @@ -439,6 +450,7 @@ export async function archiveAssessment( return request('POST', `/api/assessments/${id}/archive`, { archived, ...(user !== undefined ? { user } : {}), + ...(currentUserId() !== undefined ? { userId: currentUserId() } : {}), }); } @@ -473,6 +485,8 @@ export interface AssessmentDetailResponse { readonly expiresAt?: string | null; /** 审批人指派(按审批线,软约束)。 */ readonly assignment?: { riskReviewerName: string | null; managerName: string | null } | null; + /** 发起人显示名(按 assessorId 解析)。 */ + readonly assessorName?: string | null; } /** 获取单条评估详情。 */ @@ -510,6 +524,7 @@ export async function reviewAssessment( return request('POST', `/api/assessments/${id}/review`, { action, user, + ...(currentUserId() !== undefined ? { userId: currentUserId() } : {}), ...(comment !== undefined && comment !== '' ? { comment } : {}), }); } @@ -525,6 +540,7 @@ export async function approveAssessment( return request('POST', `/api/assessments/${id}/approve`, { action, user, + ...(currentUserId() !== undefined ? { userId: currentUserId() } : {}), ...(comment !== undefined && comment !== '' ? { comment } : {}), ...(rejectTo !== undefined ? { rejectTo } : {}), }); @@ -532,12 +548,12 @@ export async function approveAssessment( /** 重新提交评估。 */ export async function resubmitAssessment(id: string, user: string): Promise<{ status: WorkflowStatus }> { - return request('POST', `/api/assessments/${id}/resubmit`, { user }); + return request('POST', `/api/assessments/${id}/resubmit`, { user, ...(currentUserId() !== undefined ? { userId: currentUserId() } : {}) }); } /** 申报:将草稿评估报送风控审核。 */ export async function submitAssessment(id: string, user: string): Promise<{ status: WorkflowStatus }> { - return request('POST', `/api/assessments/${id}/submit`, { user }); + return request('POST', `/api/assessments/${id}/submit`, { user, ...(currentUserId() !== undefined ? { userId: currentUserId() } : {}) }); } /** 应用预测准确度校准(调整目标净利率基准,管理层)。 */ @@ -550,7 +566,7 @@ export async function submitRedlineVerdict( id: string, params: { redlineId: string; status: '命中' | '未命中'; note?: string; user: string; role?: string; title?: string }, ): Promise<{ redlineId: string; status: string; acceptability: string }> { - return request('POST', `/api/assessments/${id}/redline-verdict`, params); + return request('POST', `/api/assessments/${id}/redline-verdict`, { ...params, ...(currentUserId() !== undefined ? { userId: currentUserId() } : {}) }); } /** 管理层对「已通过」或「已放弃」项目直接调整工作流状态(留痕)。 */ @@ -563,6 +579,7 @@ export async function overrideAssessmentStatus( return request('POST', `/api/assessments/${id}/override`, { status, user, + ...(currentUserId() !== undefined ? { userId: currentUserId() } : {}), ...(comment !== undefined && comment !== '' ? { comment } : {}), }); } @@ -987,7 +1004,7 @@ export async function updateAssessment( const res = await fetch(`${API_BASE}/api/assessments/${id}`, { method: 'PUT', headers: { 'Content-Type': 'application/json', ...authHeader() }, - body: JSON.stringify(data), + body: JSON.stringify({ ...data, ...(currentUserId() !== undefined ? { userId: currentUserId() } : {}) }), }); if (!res.ok) { const err = await res.json().catch(() => ({})) as { error?: string }; diff --git a/web/src/pages/Dashboard.tsx b/web/src/pages/Dashboard.tsx index 3588ce5..c91bf6c 100644 --- a/web/src/pages/Dashboard.tsx +++ b/web/src/pages/Dashboard.tsx @@ -159,7 +159,7 @@ export function Dashboard(): JSX.Element { fetch(`${API_BASE}/api/accuracy`).then((r) => r.json()).then(setAccuracy).catch(() => undefined); // 草稿箱(仅销售展示):列出当前用户的向导草稿。 if (role === '商务/销售') { - listDrafts(user?.username ?? undefined).then(setDrafts).catch(() => setDrafts([])); + listDrafts(user?.id ?? undefined).then(setDrafts).catch(() => setDrafts([])); } else { setDrafts([]); } @@ -207,7 +207,7 @@ export function Dashboard(): JSX.Element { {display} - 发起人:{r.assessorId} + 发起人:{r.assessorName ?? r.assessorId} ); }, @@ -517,16 +517,16 @@ export function Dashboard(): JSX.Element { )} {todoItems.length > 0 && (() => { - const myName = user?.username; + const myId = user?.id; const assignedToMe = (id: string): boolean => { const a = assignments[id]; if (a === undefined) return false; - return role === '风控' ? a.riskReviewerName === myName : role === '管理层' ? a.managerName === myName : false; + return role === '风控' ? a.riskReviewerId === myId : role === '管理层' ? a.managerId === myId : false; }; const isAssigned = (id: string): boolean => { const a = assignments[id]; if (a === undefined) return false; - return role === '风控' ? a.riskReviewerName !== null : role === '管理层' ? a.managerName !== null : false; + return role === '风控' ? a.riskReviewerId !== null : role === '管理层' ? a.managerId !== null : false; }; // 软约束:默认只看分给我的;未指派的也展示(避免遗漏)。 const shown = onlyMine ? todoItems.filter((t) => assignedToMe(t.id) || !isAssigned(t.id)) : todoItems; @@ -536,8 +536,9 @@ export function Dashboard(): JSX.Element { render: (r) => { const a = assignments[r.id]; const name = a !== undefined ? (role === '管理层' ? a.managerName : a.riskReviewerName) : null; + const aid = a !== undefined ? (role === '管理层' ? a.managerId : a.riskReviewerId) : null; if (name === null || name === undefined) return 未指派; - const mine = name === myName; + const mine = aid === myId; return {name}{mine ? '(我)' : ''}; }, }; diff --git a/web/src/pages/NewAssessment.tsx b/web/src/pages/NewAssessment.tsx index 77c85b8..3a86acb 100644 --- a/web/src/pages/NewAssessment.tsx +++ b/web/src/pages/NewAssessment.tsx @@ -340,7 +340,7 @@ export function NewAssessment(): JSX.Element { const timer = setTimeout(() => { void saveDraft({ id: draftId, - assessorId: user?.username ?? null, + assessorId: user?.id ?? null, sourceAssessmentId, projectName: projectName.trim() !== '' ? projectName : null, form: buildSnapshot(), @@ -369,7 +369,7 @@ export function NewAssessment(): JSX.Element { try { await saveDraft({ id: draftId, - assessorId: user?.username ?? null, + assessorId: user?.id ?? null, sourceAssessmentId, projectName: projectName.trim() !== '' ? projectName : null, form: buildSnapshot(), @@ -700,7 +700,7 @@ export function NewAssessment(): JSX.Element { ...(effectiveEditId !== null ? { assessmentId: effectiveEditId, useLlm: false } : {}), ...(effectiveEditId !== null && editSavedAt !== null ? { expectedSavedAt: editSavedAt } : {}), ...(Number(clientTotalHeadcount) > 0 ? { clientTotalHeadcount: Number(clientTotalHeadcount) } : {}), - ...(user?.username !== undefined ? { assessorId: user.username } : {}), + ...(user?.id !== undefined ? { assessorId: user.id } : {}), knownData: Object.entries(answers) .filter((e): e is [string, number] => typeof e[1] === 'number') .map(([k, v]) => [k, v]), diff --git a/web/src/stores/authStore.ts b/web/src/stores/authStore.ts index 5f68111..2edbc80 100644 --- a/web/src/stores/authStore.ts +++ b/web/src/stores/authStore.ts @@ -31,7 +31,7 @@ export const TEST_ACCOUNTS: readonly { /** 认证状态。 */ export interface AuthState { readonly isAuthenticated: boolean; - readonly user: { username: string; role: AuthRole; displayName?: string } | null; + readonly user: { id?: string; username: string; role: AuthRole; displayName?: string } | null; readonly error: string | null; login(username: string, password: string): Promise; @@ -80,10 +80,12 @@ export const useAuthStore = create((set) => ({ return false; } const role = d.role as AuthRole; - const user = { username: String(d.username ?? username), role, ...(d.displayName ? { displayName: String(d.displayName) } : {}) }; + const user = { ...(d.id ? { id: String(d.id) } : {}), username: String(d.username ?? username), role, ...(d.displayName ? { displayName: String(d.displayName) } : {}) }; saveToStorage(user); if (typeof d.token === 'string' && d.token !== '') localStorage.setItem('risk-agent-token', d.token); else localStorage.removeItem('risk-agent-token'); + if (d.id) localStorage.setItem('risk-agent-uid', String(d.id)); + else localStorage.removeItem('risk-agent-uid'); set({ isAuthenticated: true, user, error: null }); return true; } catch { @@ -94,7 +96,7 @@ export const useAuthStore = create((set) => ({ logout: () => { saveToStorage(null); - try { localStorage.removeItem('risk-agent-token'); } catch { /* ignore */ } + try { localStorage.removeItem('risk-agent-token'); localStorage.removeItem('risk-agent-uid'); } catch { /* ignore */ } set({ isAuthenticated: false, user: null, error: null }); },