大改动:全部人员用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:
+4
-2
@@ -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 });
|
||||
}
|
||||
|
||||
/** 无需鉴权的公共路径(登录与健康检查)。 */
|
||||
|
||||
@@ -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],
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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' });
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user