大改动:全部人员用ID关联(JWT带uid + audit actor_id + assessorId改用户ID + 看板按ID匹配 + 历史回填)

- 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),本地+生产已执行
This commit is contained in:
freedakgmail
2026-06-13 19:18:17 +08:00
parent e86e60208f
commit 5afe021b56
10 changed files with 194 additions and 47 deletions
+14
View File
@@ -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;`);
};
+37
View File
@@ -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;
+4 -2
View File
@@ -13,6 +13,8 @@ export type AuthRole = '商务/销售' | '风控' | '管理层' | '系统管理
export interface AuthPayload { export interface AuthPayload {
username: string; username: string;
role: AuthRole; role: AuthRole;
/** 用户 ID(稳定锚,用于记录关联)。旧 token 可能缺省。 */
uid?: string;
iat: number; iat: number;
exp: number; exp: number;
} }
@@ -48,9 +50,9 @@ function verify(token: string): AuthPayload | null {
} }
/** 签发 JWT(登录成功后调用)。 */ /** 签发 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); 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 });
} }
/** 无需鉴权的公共路径(登录与健康检查)。 */ /** 无需鉴权的公共路径(登录与健康检查)。 */
+8 -4
View File
@@ -35,7 +35,10 @@ export function poolOptionsFromEnv(): Pick<
/** 操作记录条目(与服务端一致)。 */ /** 操作记录条目(与服务端一致)。 */
export interface AuditLogEntry { export interface AuditLogEntry {
readonly role: string; readonly role: string;
/** 操作人显示名(当时快照)。 */
readonly username: string; readonly username: string;
/** 操作人用户 ID(关联锚;历史记录可能缺省)。 */
readonly actorId?: string;
readonly action: string; readonly action: string;
readonly comment?: string; readonly comment?: string;
readonly timestamp: string; readonly timestamp: string;
@@ -362,7 +365,7 @@ export async function loadWorkflowState(pool: Pool): Promise<{
} }
const a = await pool.query( 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<Record<string, unknown>>) { for (const r of a.rows as Array<Record<string, unknown>>) {
const id = String(r.assessment_id); const id = String(r.assessment_id);
@@ -370,6 +373,7 @@ export async function loadWorkflowState(pool: Pool): Promise<{
list.push({ list.push({
role: String(r.role), role: String(r.role),
username: String(r.username), username: String(r.username),
...(r.actor_id != null ? { actorId: String(r.actor_id) } : {}),
action: String(r.action), action: String(r.action),
...(r.comment != null ? { comment: String(r.comment) } : {}), ...(r.comment != null ? { comment: String(r.comment) } : {}),
timestamp: toIso(r.ts), 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<void> { export async function persistAudit(pool: Pool, id: string, entry: AuditLogEntry): Promise<void> {
await pool.query( await pool.query(
`INSERT INTO audit_logs(assessment_id, role, username, action, comment, ts) `INSERT INTO audit_logs(assessment_id, role, username, actor_id, action, comment, ts)
VALUES($1, $2, $3, $4, $5, $6)`, VALUES($1, $2, $3, $4, $5, $6, $7)`,
[id, entry.role, entry.username, entry.action, entry.comment ?? null, entry.timestamp], [id, entry.role, entry.username, entry.actorId ?? null, entry.action, entry.comment ?? null, entry.timestamp],
); );
} }
+10
View File
@@ -55,6 +55,16 @@ export async function listUsers(pool: pg.Pool): Promise<UserRecord[]> {
return (res.rows as Array<Record<string, unknown>>).map(mapRow); return (res.rows as Array<Record<string, unknown>>).map(mapRow);
} }
/** 按 ID 取用户(不含密码)。 */
export async function getUserById(pool: pg.Pool, id: string): Promise<UserRecord | null> {
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<Record<string, unknown>>)[0];
return r ? mapRow(r) : null;
}
/** 按用户名取用户(含密码哈希,仅供登录校验内部使用)。 */ /** 按用户名取用户(含密码哈希,仅供登录校验内部使用)。 */
export async function getUserByUsername( export async function getUserByUsername(
pool: pg.Pool, pool: pg.Pool,
+85 -25
View File
@@ -72,6 +72,7 @@ import {
ensureSeedUsers, ensureSeedUsers,
USER_ROLES, USER_ROLES,
type UserRole, type UserRole,
getUserById,
getApprovalConfig, getApprovalConfig,
saveApprovalConfig, saveApprovalConfig,
ensureApprovalConfig, ensureApprovalConfig,
@@ -123,7 +124,7 @@ import {
type SynthesisContext, type SynthesisContext,
} from '../llm/index.js'; } from '../llm/index.js';
import type { ResolvedDataPoint } from '../adapters/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 { BusinessType, Industry } from '../domain/common.js';
import type { Indicator } from '../domain/index.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)) { if (user === null || !user.active || !verifyPassword(password, user.passwordHash)) {
return c.json({ error: '用户名或密码错误,或账号已停用' }, 401); return c.json({ error: '用户名或密码错误,或账号已停用' }, 401);
} }
const token = issueToken(user.username, user.role as AuthRole); const token = issueToken(user.username, user.role as AuthRole, user.id);
return c.json({ token, username: user.username, role: user.role, displayName: user.displayName }); 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'); setStatus(result.assessment.id, 'draft');
const actC = await resolveActor(c, body.assessorId, undefined);
const submitEntry: AuditLogEntry = { const submitEntry: AuditLogEntry = {
role: '商务/销售', role: '商务/销售',
username: result.assessment.assessorId ?? '系统', username: actC.name,
...(actC.id !== null ? { actorId: actC.id } : {}),
action: isEdit ? '编辑并重新评估(草稿待申报)' : '创建评估(草稿待申报)', action: isEdit ? '编辑并重新评估(草稿待申报)' : '创建评估(草稿待申报)',
timestamp: new Date().toISOString(), timestamp: new Date().toISOString(),
}; };
@@ -1538,6 +1541,36 @@ function setStatus(id: string, status: WorkflowStatus): void {
workflowStatus.set(id, status); 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<Record<string, string>> {
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 { function addAuditLog(id: string, entry: AuditLogEntry): void {
const existing = auditLogs.get(id) ?? []; const existing = auditLogs.get(id) ?? [];
auditLogs.set(id, [...existing, entry]); auditLogs.set(id, [...existing, entry]);
@@ -1601,7 +1634,12 @@ app.get('/api/assessments', async (c) => {
...(statusFilter !== undefined ? { status: statusFilter } : {}), ...(statusFilter !== undefined ? { status: statusFilter } : {}),
...(q !== undefined ? { q } : {}), ...(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:内存过滤 + 分页(小数据量)。 // 无 DB:内存过滤 + 分页(小数据量)。
@@ -1695,6 +1733,8 @@ app.get('/api/assessments/:id', async (c) => {
} }
// 审批人指派(按审批线,软约束)。 // 审批人指派(按审批线,软约束)。
const assignment = pool !== null ? await getAssignment(pool, id).catch(() => null) : null; 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({ return c.json({
assessment: record.assessment, assessment: record.assessment,
report: record.report, report: record.report,
@@ -1710,6 +1750,7 @@ app.get('/api/assessments/:id', async (c) => {
profitabilityInputs: profitabilityInputsById.get(id) ?? null, profitabilityInputs: profitabilityInputsById.get(id) ?? null,
expiresAt, expiresAt,
assignment, assignment,
assessorName,
}); });
}); });
@@ -1756,10 +1797,12 @@ app.post('/api/assessments/:id/archive', async (c) => {
return c.json({ error: '评估记录不存在' }, 404); return c.json({ error: '评估记录不存在' }, 404);
} }
const body = await c.req const body = await c.req
.json<{ archived?: boolean; user?: string }>() .json<{ archived?: boolean; user?: string; userId?: string }>()
.catch(() => ({}) as { archived?: boolean; user?: string }); .catch(() => ({}) as { archived?: boolean; user?: string; userId?: string });
const archived = body.archived !== false; // 缺省视为归档 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) { if (archived) {
const current = getStatus(id) ?? 'pending_risk_review'; const current = getStatus(id) ?? 'pending_risk_review';
@@ -1770,6 +1813,7 @@ app.post('/api/assessments/:id/archive', async (c) => {
const entry: AuditLogEntry = { const entry: AuditLogEntry = {
role: '流程', role: '流程',
username, username,
...actorIdPart,
action: '归档', action: '归档',
timestamp: new Date().toISOString(), timestamp: new Date().toISOString(),
}; };
@@ -1789,6 +1833,7 @@ app.post('/api/assessments/:id/archive', async (c) => {
const entry: AuditLogEntry = { const entry: AuditLogEntry = {
role: '流程', role: '流程',
username, username,
...actorIdPart,
action: '取消归档(重置为待风控审核)', action: '取消归档(重置为待风控审核)',
timestamp: new Date().toISOString(), timestamp: new Date().toISOString(),
}; };
@@ -1869,11 +1914,12 @@ app.post('/api/assessments/:id/review', requireRole('风控'), async (c) => {
if (record === undefined) { if (record === undefined) {
return c.json({ error: '评估记录不存在' }, 404); 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'; const current = getStatus(id) ?? 'pending_risk_review';
if (current !== 'pending_risk_review') { if (current !== 'pending_risk_review') {
return c.json({ error: `当前状态为 ${current},无法审核` }, 400); return c.json({ error: `当前状态为 ${current},无法审核` }, 400);
} }
const act = await resolveActor(c, body.userId, body.user);
let newStatus: WorkflowStatus; let newStatus: WorkflowStatus;
let actionText: string; let actionText: string;
if (body.action === 'approve') { if (body.action === 'approve') {
@@ -1918,7 +1964,8 @@ app.post('/api/assessments/:id/review', requireRole('风控'), async (c) => {
setStatus(id, newStatus); setStatus(id, newStatus);
const reviewEntry: AuditLogEntry = { const reviewEntry: AuditLogEntry = {
role: '风控', role: '风控',
username: body.user, username: act.name,
...(act.id !== null ? { actorId: act.id } : {}),
action: actionText, action: actionText,
...(body.comment !== undefined && body.comment !== '' ? { comment: body.comment } : {}), ...(body.comment !== undefined && body.comment !== '' ? { comment: body.comment } : {}),
timestamp: new Date().toISOString(), timestamp: new Date().toISOString(),
@@ -1938,7 +1985,7 @@ app.post('/api/assessments/:id/approve', requireRole('管理层'), async (c) =>
if (record === undefined) { if (record === undefined) {
return c.json({ error: '评估记录不存在' }, 404); 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'; const current = getStatus(id) ?? 'pending_risk_review';
if (current !== 'risk_reviewed' && current !== 'pending_risk_review') { if (current !== 'risk_reviewed' && current !== 'pending_risk_review') {
return c.json({ error: `当前状态为 ${current},无法审批` }, 400); return c.json({ error: `当前状态为 ${current},无法审批` }, 400);
@@ -1962,9 +2009,11 @@ app.post('/api/assessments/:id/approve', requireRole('管理层'), async (c) =>
actionText = '管理层驳回(退回风控复审)'; actionText = '管理层驳回(退回风控复审)';
} }
setStatus(id, newStatus); setStatus(id, newStatus);
const act = await resolveActor(c, body.userId, body.user);
const approveEntry: AuditLogEntry = { const approveEntry: AuditLogEntry = {
role: '管理层', role: '管理层',
username: body.user, username: act.name,
...(act.id !== null ? { actorId: act.id } : {}),
action: actionText, action: actionText,
...(body.comment !== undefined && body.comment !== '' ? { comment: body.comment } : {}), ...(body.comment !== undefined && body.comment !== '' ? { comment: body.comment } : {}),
timestamp: new Date().toISOString(), timestamp: new Date().toISOString(),
@@ -1987,7 +2036,7 @@ app.post('/api/assessments/:id/override', requireRole('管理层'), async (c) =>
if (record === undefined) { if (record === undefined) {
return c.json({ error: '评估记录不存在' }, 404); 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'; const current = getStatus(id) ?? 'pending_risk_review';
if (current !== 'approved' && current !== 'abandoned') { if (current !== 'approved' && current !== 'abandoned') {
return c.json({ error: '仅可对「已通过」或「已放弃」的项目直接调整状态' }, 400); 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); return c.json({ error: `非法目标状态:${body.status}` }, 400);
} }
setStatus(id, body.status); setStatus(id, body.status);
const actOv = await resolveActor(c, body.userId, body.user);
const entry: AuditLogEntry = { const entry: AuditLogEntry = {
role: '管理层', role: '管理层',
username: body.user, username: actOv.name,
...(actOv.id !== null ? { actorId: actOv.id } : {}),
action: `管理层直接调整状态(${STATUS_CN[current]}${STATUS_CN[body.status]}`, action: `管理层直接调整状态(${STATUS_CN[current]}${STATUS_CN[body.status]}`,
...(body.comment !== undefined && body.comment !== '' ? { comment: body.comment } : {}), ...(body.comment !== undefined && body.comment !== '' ? { comment: body.comment } : {}),
timestamp: new Date().toISOString(), timestamp: new Date().toISOString(),
@@ -2038,6 +2089,7 @@ app.put('/api/assessments/:id', async (c) => {
region?: string; region?: string;
profitabilityInputs?: ProfitabilityInputs; profitabilityInputs?: ProfitabilityInputs;
user?: string; user?: string;
userId?: string;
}>(); }>();
// 更新评估记录字段 // 更新评估记录字段
@@ -2161,9 +2213,11 @@ app.put('/api/assessments/:id', async (c) => {
await flushStore(); await flushStore();
// 审计留痕 // 审计留痕
const actE = await resolveActor(c, body.userId, body.user);
const editEntry: AuditLogEntry = { const editEntry: AuditLogEntry = {
role: '商务/销售', role: '商务/销售',
username: body.user ?? '销售', username: actE.name,
...(actE.id !== null ? { actorId: actE.id } : {}),
action: '修改项目资料(驳回后调整)', action: '修改项目资料(驳回后调整)',
timestamp: new Date().toISOString(), timestamp: new Date().toISOString(),
}; };
@@ -2185,7 +2239,7 @@ app.post('/api/assessments/:id/redline-verdict', requireRole('风控', '管理
if (record === undefined) { if (record === undefined) {
return c.json({ error: '评估记录不存在' }, 404); 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 !== '未命中') { if (body.status !== '命中' && body.status !== '未命中') {
return c.json({ error: '裁定状态须为「命中」或「未命中」' }, 400); 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() }); store.put({ ...record, assessment: a, report: updatedReport, savedAt: new Date().toISOString() });
await flushStore(); await flushStore();
const actV = await resolveActor(c, body.userId, body.user);
const entry: AuditLogEntry = { const entry: AuditLogEntry = {
role: body.role ?? '风控', role: body.role ?? '风控',
username: body.user, username: actV.name,
...(actV.id !== null ? { actorId: actV.id } : {}),
action: `人工裁定红线「${title}」→ ${body.status}`, action: `人工裁定红线「${title}」→ ${body.status}`,
...(note !== '' ? { comment: note } : {}), ...(note !== '' ? { comment: note } : {}),
timestamp: new Date().toISOString(), timestamp: new Date().toISOString(),
@@ -2252,12 +2308,11 @@ app.post('/api/assessments/:id/redline-verdict', requireRole('风控', '管理
* 按审批线为评估指派审批人(软约束,仅展示/过滤/审计)。 * 按审批线为评估指派审批人(软约束,仅展示/过滤/审计)。
* 据提交销售解析审批线 → 指定风控/管理层;写入 assessment_assignments。 * 据提交销售解析审批线 → 指定风控/管理层;写入 assessment_assignments。
*/ */
async function assignApprovers(id: string, salesUsername: string): Promise<void> { async function assignApprovers(id: string, salesId: string | undefined): Promise<void> {
if (pool === null) return; if (pool === null) return;
const cfg = await getApprovalConfig(pool); const cfg = await getApprovalConfig(pool);
if (!cfg.assignment.enabled) return; if (!cfg.assignment.enabled) return;
const sales = await getUserByUsername(pool, salesUsername); const { riskReviewerId, managerId } = resolveAssignees(cfg.assignment, salesId);
const { riskReviewerId, managerId } = resolveAssignees(cfg.assignment, sales?.id);
const users = await listUsers(pool); const users = await listUsers(pool);
const nameOf = (uid: string | null): string | null => { const nameOf = (uid: string | null): string | null => {
if (uid === null) return null; if (uid === null) return null;
@@ -2280,15 +2335,17 @@ app.post('/api/assessments/:id/submit', async (c) => {
if (record === undefined) { if (record === undefined) {
return c.json({ error: '评估记录不存在' }, 404); 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'; const current = getStatus(id) ?? 'pending_risk_review';
if (current !== 'draft') { if (current !== 'draft') {
return c.json({ error: `当前状态为 ${current},无法申报` }, 400); return c.json({ error: `当前状态为 ${current},无法申报` }, 400);
} }
const actS = await resolveActor(c, body.userId, body.user);
setStatus(id, 'pending_risk_review'); setStatus(id, 'pending_risk_review');
const submitEntry: AuditLogEntry = { const submitEntry: AuditLogEntry = {
role: '商务/销售', role: '商务/销售',
username: body.user, username: actS.name,
...(actS.id !== null ? { actorId: actS.id } : {}),
action: '申报(报送风控审核)', action: '申报(报送风控审核)',
timestamp: new Date().toISOString(), timestamp: new Date().toISOString(),
}; };
@@ -2296,7 +2353,7 @@ app.post('/api/assessments/:id/submit', async (c) => {
if (pool !== null) { if (pool !== null) {
await persistStatus(pool, id, 'pending_risk_review'); await persistStatus(pool, id, 'pending_risk_review');
await persistAudit(pool, id, submitEntry); 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' }); return c.json({ status: 'pending_risk_review' });
}); });
@@ -2308,15 +2365,17 @@ app.post('/api/assessments/:id/resubmit', async (c) => {
if (record === undefined) { if (record === undefined) {
return c.json({ error: '评估记录不存在' }, 404); 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'; const current = getStatus(id) ?? 'pending_risk_review';
if (current !== 'rejected') { if (current !== 'rejected') {
return c.json({ error: `当前状态为 ${current},无需重新提交` }, 400); return c.json({ error: `当前状态为 ${current},无需重新提交` }, 400);
} }
const actR = await resolveActor(c, body.userId, body.user);
setStatus(id, 'pending_risk_review'); setStatus(id, 'pending_risk_review');
const resubmitEntry: AuditLogEntry = { const resubmitEntry: AuditLogEntry = {
role: '商务/销售', role: '商务/销售',
username: body.user, username: actR.name,
...(actR.id !== null ? { actorId: actR.id } : {}),
action: '重新提交评估', action: '重新提交评估',
timestamp: new Date().toISOString(), timestamp: new Date().toISOString(),
}; };
@@ -2324,6 +2383,7 @@ app.post('/api/assessments/:id/resubmit', async (c) => {
if (pool !== null) { if (pool !== null) {
await persistStatus(pool, id, 'pending_risk_review'); await persistStatus(pool, id, 'pending_risk_review');
await persistAudit(pool, id, resubmitEntry); await persistAudit(pool, id, resubmitEntry);
await assignApprovers(id, actR.id ?? undefined).catch(() => undefined);
} }
return c.json({ status: 'pending_risk_review' }); return c.json({ status: 'pending_risk_review' });
}); });
+21 -4
View File
@@ -24,6 +24,15 @@ function authHeader(): Record<string, string> {
} }
} }
/** 当前登录用户 ID(用于在记录中以 ID 关联操作人;演示模式下作为 body 兜底)。 */
export function currentUserId(): string | undefined {
try {
return localStorage.getItem('risk-agent-uid') ?? undefined;
} catch {
return undefined;
}
}
/** API 调用错误。 */ /** API 调用错误。 */
export class ApiError extends Error { export class ApiError extends Error {
constructor( constructor(
@@ -118,6 +127,8 @@ export interface AssessmentListItem {
readonly acceptability?: string; readonly acceptability?: string;
readonly createdAt: string; readonly createdAt: string;
readonly assessorId: string; readonly assessorId: string;
/** 发起人显示名(按 assessorId 解析)。 */
readonly assessorName?: string;
readonly status: WorkflowStatus; readonly status: WorkflowStatus;
readonly auditLog: AuditLogEntry[]; readonly auditLog: AuditLogEntry[];
readonly archived?: boolean; readonly archived?: boolean;
@@ -439,6 +450,7 @@ export async function archiveAssessment(
return request('POST', `/api/assessments/${id}/archive`, { return request('POST', `/api/assessments/${id}/archive`, {
archived, archived,
...(user !== undefined ? { user } : {}), ...(user !== undefined ? { user } : {}),
...(currentUserId() !== undefined ? { userId: currentUserId() } : {}),
}); });
} }
@@ -473,6 +485,8 @@ export interface AssessmentDetailResponse {
readonly expiresAt?: string | null; readonly expiresAt?: string | null;
/** 审批人指派(按审批线,软约束)。 */ /** 审批人指派(按审批线,软约束)。 */
readonly assignment?: { riskReviewerName: string | null; managerName: string | null } | 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`, { return request('POST', `/api/assessments/${id}/review`, {
action, action,
user, user,
...(currentUserId() !== undefined ? { userId: currentUserId() } : {}),
...(comment !== undefined && comment !== '' ? { comment } : {}), ...(comment !== undefined && comment !== '' ? { comment } : {}),
}); });
} }
@@ -525,6 +540,7 @@ export async function approveAssessment(
return request('POST', `/api/assessments/${id}/approve`, { return request('POST', `/api/assessments/${id}/approve`, {
action, action,
user, user,
...(currentUserId() !== undefined ? { userId: currentUserId() } : {}),
...(comment !== undefined && comment !== '' ? { comment } : {}), ...(comment !== undefined && comment !== '' ? { comment } : {}),
...(rejectTo !== undefined ? { rejectTo } : {}), ...(rejectTo !== undefined ? { rejectTo } : {}),
}); });
@@ -532,12 +548,12 @@ export async function approveAssessment(
/** 重新提交评估。 */ /** 重新提交评估。 */
export async function resubmitAssessment(id: string, user: string): Promise<{ status: WorkflowStatus }> { 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 }> { 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, id: string,
params: { redlineId: string; status: '命中' | '未命中'; note?: string; user: string; role?: string; title?: string }, params: { redlineId: string; status: '命中' | '未命中'; note?: string; user: string; role?: string; title?: string },
): Promise<{ redlineId: string; status: string; acceptability: 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`, { return request('POST', `/api/assessments/${id}/override`, {
status, status,
user, user,
...(currentUserId() !== undefined ? { userId: currentUserId() } : {}),
...(comment !== undefined && comment !== '' ? { comment } : {}), ...(comment !== undefined && comment !== '' ? { comment } : {}),
}); });
} }
@@ -987,7 +1004,7 @@ export async function updateAssessment(
const res = await fetch(`${API_BASE}/api/assessments/${id}`, { const res = await fetch(`${API_BASE}/api/assessments/${id}`, {
method: 'PUT', method: 'PUT',
headers: { 'Content-Type': 'application/json', ...authHeader() }, headers: { 'Content-Type': 'application/json', ...authHeader() },
body: JSON.stringify(data), body: JSON.stringify({ ...data, ...(currentUserId() !== undefined ? { userId: currentUserId() } : {}) }),
}); });
if (!res.ok) { if (!res.ok) {
const err = await res.json().catch(() => ({})) as { error?: string }; const err = await res.json().catch(() => ({})) as { error?: string };
+7 -6
View File
@@ -159,7 +159,7 @@ export function Dashboard(): JSX.Element {
fetch(`${API_BASE}/api/accuracy`).then((r) => r.json()).then(setAccuracy).catch(() => undefined); fetch(`${API_BASE}/api/accuracy`).then((r) => r.json()).then(setAccuracy).catch(() => undefined);
// 草稿箱(仅销售展示):列出当前用户的向导草稿。 // 草稿箱(仅销售展示):列出当前用户的向导草稿。
if (role === '商务/销售') { if (role === '商务/销售') {
listDrafts(user?.username ?? undefined).then(setDrafts).catch(() => setDrafts([])); listDrafts(user?.id ?? undefined).then(setDrafts).catch(() => setDrafts([]));
} else { } else {
setDrafts([]); setDrafts([]);
} }
@@ -207,7 +207,7 @@ export function Dashboard(): JSX.Element {
<span style={{ fontWeight: 700, color: colorVar('color.text.primary'), whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}> <span style={{ fontWeight: 700, color: colorVar('color.text.primary'), whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>
{display} {display}
</span> </span>
<span style={{ ...typographyStyle('caption'), color: colorVar('color.text.secondary'), whiteSpace: 'nowrap' }}>{r.assessorId}</span> <span style={{ ...typographyStyle('caption'), color: colorVar('color.text.secondary'), whiteSpace: 'nowrap' }}>{r.assessorName ?? r.assessorId}</span>
</div> </div>
); );
}, },
@@ -517,16 +517,16 @@ export function Dashboard(): JSX.Element {
)} )}
{todoItems.length > 0 && (() => { {todoItems.length > 0 && (() => {
const myName = user?.username; const myId = user?.id;
const assignedToMe = (id: string): boolean => { const assignedToMe = (id: string): boolean => {
const a = assignments[id]; const a = assignments[id];
if (a === undefined) return false; 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 isAssigned = (id: string): boolean => {
const a = assignments[id]; const a = assignments[id];
if (a === undefined) return false; 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; const shown = onlyMine ? todoItems.filter((t) => assignedToMe(t.id) || !isAssigned(t.id)) : todoItems;
@@ -536,8 +536,9 @@ export function Dashboard(): JSX.Element {
render: (r) => { render: (r) => {
const a = assignments[r.id]; const a = assignments[r.id];
const name = a !== undefined ? (role === '管理层' ? a.managerName : a.riskReviewerName) : null; 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 <span style={{ color: colorVar('color.text.secondary') }}></span>; if (name === null || name === undefined) return <span style={{ color: colorVar('color.text.secondary') }}></span>;
const mine = name === myName; const mine = aid === myId;
return <span style={{ ...typographyStyle('caption'), fontWeight: 600, color: mine ? '#15803D' : colorVar('color.text.primary') }}>{name}{mine ? '(我)' : ''}</span>; return <span style={{ ...typographyStyle('caption'), fontWeight: 600, color: mine ? '#15803D' : colorVar('color.text.primary') }}>{name}{mine ? '(我)' : ''}</span>;
}, },
}; };
+3 -3
View File
@@ -340,7 +340,7 @@ export function NewAssessment(): JSX.Element {
const timer = setTimeout(() => { const timer = setTimeout(() => {
void saveDraft({ void saveDraft({
id: draftId, id: draftId,
assessorId: user?.username ?? null, assessorId: user?.id ?? null,
sourceAssessmentId, sourceAssessmentId,
projectName: projectName.trim() !== '' ? projectName : null, projectName: projectName.trim() !== '' ? projectName : null,
form: buildSnapshot(), form: buildSnapshot(),
@@ -369,7 +369,7 @@ export function NewAssessment(): JSX.Element {
try { try {
await saveDraft({ await saveDraft({
id: draftId, id: draftId,
assessorId: user?.username ?? null, assessorId: user?.id ?? null,
sourceAssessmentId, sourceAssessmentId,
projectName: projectName.trim() !== '' ? projectName : null, projectName: projectName.trim() !== '' ? projectName : null,
form: buildSnapshot(), form: buildSnapshot(),
@@ -700,7 +700,7 @@ export function NewAssessment(): JSX.Element {
...(effectiveEditId !== null ? { assessmentId: effectiveEditId, useLlm: false } : {}), ...(effectiveEditId !== null ? { assessmentId: effectiveEditId, useLlm: false } : {}),
...(effectiveEditId !== null && editSavedAt !== null ? { expectedSavedAt: editSavedAt } : {}), ...(effectiveEditId !== null && editSavedAt !== null ? { expectedSavedAt: editSavedAt } : {}),
...(Number(clientTotalHeadcount) > 0 ? { clientTotalHeadcount: Number(clientTotalHeadcount) } : {}), ...(Number(clientTotalHeadcount) > 0 ? { clientTotalHeadcount: Number(clientTotalHeadcount) } : {}),
...(user?.username !== undefined ? { assessorId: user.username } : {}), ...(user?.id !== undefined ? { assessorId: user.id } : {}),
knownData: Object.entries(answers) knownData: Object.entries(answers)
.filter((e): e is [string, number] => typeof e[1] === 'number') .filter((e): e is [string, number] => typeof e[1] === 'number')
.map(([k, v]) => [k, v]), .map(([k, v]) => [k, v]),
+5 -3
View File
@@ -31,7 +31,7 @@ export const TEST_ACCOUNTS: readonly {
/** 认证状态。 */ /** 认证状态。 */
export interface AuthState { export interface AuthState {
readonly isAuthenticated: boolean; 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; readonly error: string | null;
login(username: string, password: string): Promise<boolean>; login(username: string, password: string): Promise<boolean>;
@@ -80,10 +80,12 @@ export const useAuthStore = create<AuthState>((set) => ({
return false; return false;
} }
const role = d.role as AuthRole; 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); saveToStorage(user);
if (typeof d.token === 'string' && d.token !== '') localStorage.setItem('risk-agent-token', d.token); if (typeof d.token === 'string' && d.token !== '') localStorage.setItem('risk-agent-token', d.token);
else localStorage.removeItem('risk-agent-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 }); set({ isAuthenticated: true, user, error: null });
return true; return true;
} catch { } catch {
@@ -94,7 +96,7 @@ export const useAuthStore = create<AuthState>((set) => ({
logout: () => { logout: () => {
saveToStorage(null); 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 }); set({ isAuthenticated: false, user: null, error: null });
}, },