大改动:全部人员用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
+4 -2
View File
@@ -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 });
}
/** 无需鉴权的公共路径(登录与健康检查)。 */
+8 -4
View File
@@ -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<Record<string, unknown>>) {
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<void> {
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],
);
}
+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);
}
/** 按 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(
pool: pg.Pool,
+85 -25
View File
@@ -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<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 {
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<void> {
async function assignApprovers(id: string, salesId: string | undefined): Promise<void> {
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' });
});