多用户与角色:新增系统管理员 + 用户管理

- 新增 users 表(scrypt 口令哈希)与持久化层,启动兜底种子账号
- 登录改为后端用户表校验账号密码;JWT 带角色;保留无DB演示回退
- 新增系统管理员角色 + 用户管理页(增删改/改角色/启停/重置密码)
- 用户管理端点按 系统管理员 角色强制校验(RBAC)
- 各角色可建任意多个账号(多销售/多风控/多管理)
- 更新登录页快速登录与首屏快照
This commit is contained in:
freedakgmail
2026-06-13 17:35:52 +08:00
parent 2537e5beef
commit 6562208b13
12 changed files with 563 additions and 42 deletions
+1 -1
View File
@@ -8,7 +8,7 @@
import type { Context, Next } from 'hono';
import { createHmac } from 'node:crypto';
export type AuthRole = '商务/销售' | '风控' | '管理层';
export type AuthRole = '商务/销售' | '风控' | '管理层' | '系统管理员';
export interface AuthPayload {
username: string;
+1
View File
@@ -19,6 +19,7 @@ export * from './redlineRules.js';
export * from './customers.js';
export * from './minWages.js';
export * from './drafts.js';
export * from './users.js';
export * from './settings.js';
export * from './regionRates.js';
export * from './rejectReasons.js';
+148
View File
@@ -0,0 +1,148 @@
/**
* 用户账号持久化(多用户 + 角色)。
*
* 密码用 Node scrypt 派生(salt:hash),不依赖外部库;校验用 timingSafeEqual 防时序攻击。
*/
import type pg from 'pg';
import { scryptSync, randomBytes, timingSafeEqual } from 'node:crypto';
export type UserRole = '商务/销售' | '风控' | '管理层' | '系统管理员';
export const USER_ROLES: readonly UserRole[] = ['商务/销售', '风控', '管理层', '系统管理员'];
/** 对外用户记录(不含密码哈希)。 */
export interface UserRecord {
id: string;
username: string;
displayName: string | null;
role: UserRole;
active: boolean;
createdAt: string;
}
/** scrypt 派生口令哈希,返回 `salt:hash`。 */
export function hashPassword(password: string): string {
const salt = randomBytes(16).toString('hex');
const hash = scryptSync(password, salt, 64).toString('hex');
return `${salt}:${hash}`;
}
/** 校验明文口令与存储哈希是否匹配。 */
export function verifyPassword(password: string, stored: string): boolean {
const [salt, hash] = stored.split(':');
if (salt === undefined || hash === undefined || salt === '' || hash === '') return false;
const derived = scryptSync(password, salt, 64);
const expected = Buffer.from(hash, 'hex');
return derived.length === expected.length && timingSafeEqual(derived, expected);
}
function mapRow(r: Record<string, unknown>): UserRecord {
return {
id: String(r.id),
username: String(r.username),
displayName: r.display_name != null ? String(r.display_name) : null,
role: String(r.role) as UserRole,
active: r.active === true,
createdAt: r.created_at instanceof Date ? r.created_at.toISOString() : String(r.created_at),
};
}
/** 列出全部用户(不含密码)。 */
export async function listUsers(pool: pg.Pool): Promise<UserRecord[]> {
const res = await pool.query(
'SELECT id, username, display_name, role, active, created_at FROM users ORDER BY role, username',
);
return (res.rows as Array<Record<string, unknown>>).map(mapRow);
}
/** 按用户名取用户(含密码哈希,仅供登录校验内部使用)。 */
export async function getUserByUsername(
pool: pg.Pool,
username: string,
): Promise<(UserRecord & { passwordHash: string }) | null> {
const res = await pool.query(
'SELECT id, username, display_name, role, active, created_at, password_hash FROM users WHERE username = $1',
[username],
);
const r = (res.rows as Array<Record<string, unknown>>)[0];
if (!r) return null;
return { ...mapRow(r), passwordHash: String(r.password_hash) };
}
export interface CreateUserInput {
username: string;
displayName?: string | null;
password: string;
role: UserRole;
}
/** 新增用户。用户名已存在则抛错。 */
export async function createUser(pool: pg.Pool, input: CreateUserInput): Promise<UserRecord> {
const id = `user-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`;
const res = await pool.query(
`INSERT INTO users(id, username, display_name, password_hash, role, active)
VALUES($1,$2,$3,$4,$5,true)
RETURNING id, username, display_name, role, active, created_at`,
[id, input.username, input.displayName ?? null, hashPassword(input.password), input.role],
);
return mapRow((res.rows as Array<Record<string, unknown>>)[0]!);
}
export interface UpdateUserInput {
displayName?: string | null;
role?: UserRole;
active?: boolean;
}
/** 更新用户的显示名/角色/启用状态。 */
export async function updateUser(pool: pg.Pool, id: string, patch: UpdateUserInput): Promise<UserRecord | null> {
const sets: string[] = [];
const vals: unknown[] = [];
let i = 1;
if (patch.displayName !== undefined) { sets.push(`display_name = $${i++}`); vals.push(patch.displayName); }
if (patch.role !== undefined) { sets.push(`role = $${i++}`); vals.push(patch.role); }
if (patch.active !== undefined) { sets.push(`active = $${i++}`); vals.push(patch.active); }
if (sets.length === 0) {
const cur = await pool.query('SELECT id, username, display_name, role, active, created_at FROM users WHERE id=$1', [id]);
const r = (cur.rows as Array<Record<string, unknown>>)[0];
return r ? mapRow(r) : null;
}
sets.push('updated_at = now()');
vals.push(id);
const res = await pool.query(
`UPDATE users SET ${sets.join(', ')} WHERE id = $${i} RETURNING id, username, display_name, role, active, created_at`,
vals,
);
const r = (res.rows as Array<Record<string, unknown>>)[0];
return r ? mapRow(r) : null;
}
/** 重置/修改用户密码。 */
export async function setUserPassword(pool: pg.Pool, id: string, password: string): Promise<void> {
await pool.query('UPDATE users SET password_hash=$1, updated_at=now() WHERE id=$2', [hashPassword(password), id]);
}
/** 删除用户。 */
export async function deleteUser(pool: pg.Pool, id: string): Promise<void> {
await pool.query('DELETE FROM users WHERE id=$1', [id]);
}
/** 统计用户数。 */
export async function countUsers(pool: pg.Pool): Promise<number> {
const res = await pool.query('SELECT count(*)::int AS n FROM users');
return Number((res.rows as Array<{ n: number }>)[0]?.n ?? 0);
}
/** 首次启动兜底:用户表为空时创建默认账号(密码 123456,请尽快修改)。 */
export async function ensureSeedUsers(pool: pg.Pool): Promise<void> {
if ((await countUsers(pool)) > 0) return;
const seeds: CreateUserInput[] = [
{ username: '系统账号', displayName: '系统管理员', password: '123456', role: '系统管理员' },
{ username: '管理账号', displayName: '默认管理层', password: '123456', role: '管理层' },
{ username: '风控账号', displayName: '默认风控', password: '123456', role: '风控' },
{ username: '销售账号', displayName: '默认销售', password: '123456', role: '商务/销售' },
];
for (const s of seeds) {
await createUser(pool, s).catch(() => undefined);
}
}
+98 -4
View File
@@ -62,6 +62,16 @@ import {
getDraft,
upsertDraft,
deleteDraft,
listUsers,
getUserByUsername,
createUser,
updateUser,
setUserPassword,
deleteUser,
verifyPassword,
ensureSeedUsers,
USER_ROLES,
type UserRole,
getSetting,
setSetting,
loadAllRegionRates,
@@ -126,14 +136,32 @@ app.get('/api/health', (c) => c.json({ status: 'ok', version: '0.1.0' }));
/**
* POST /api/auth/login
* 登录签发 JWT(生产)。演示模式(未配置 AUTH_SECRET)下返回空 token,前端沿用本地登录
* 登录:有数据库时按 用户名+密码 校验用户表;无数据库时回退演示模式(仅按角色签发)
* 返回 JWT(未配置 AUTH_SECRET 时 token 为空,前端仍可凭返回的角色进入演示)。
*/
app.post('/api/auth/login', async (c) => {
const body = await c.req.json<{ username?: string; role?: string }>().catch(() => ({}) as { username?: string; role?: string });
const body = await c.req.json<{ username?: string; password?: string; role?: string }>().catch(() => ({}) as { username?: string; password?: string; role?: string });
const username = (body.username ?? '').trim();
const password = body.password ?? '';
if (username === '') {
return c.json({ error: '请输入用户名' }, 400);
}
// 有数据库:以用户表为准校验账号密码。
if (pool !== null) {
const user = await getUserByUsername(pool, username);
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 role = body.role as AuthRole | undefined;
if (username === '' || (role !== '商务/销售' && role !== '风控' && role !== '管理层')) {
return c.json({ error: '用户名或角色无效' }, 400);
if (role !== '商务/销售' && role !== '风控' && role !== '管理层' && role !== '系统管理员') {
return c.json({ error: '演示模式需提供合法角色' }, 400);
}
const token = issueToken(username, role);
return c.json({ token, username, role });
@@ -1193,6 +1221,71 @@ app.delete('/api/drafts/:id', async (c) => {
return c.json({ deleted: true });
});
/* ------------------------------------------------------------------ *
* 用户管理(系统管理员):多用户与角色维护。
* ------------------------------------------------------------------ */
/** 列出全部用户(系统管理员)。 */
app.get('/api/users', requireRole('系统管理员'), async (c) => {
if (pool === null) return c.json([]);
return c.json(await listUsers(pool));
});
/** 新增用户(系统管理员)。 */
app.post('/api/users', requireRole('系统管理员'), async (c) => {
if (pool === null) return c.json({ error: '未配置数据库' }, 400);
const body = await c.req.json<{ username?: string; displayName?: string; password?: string; role?: string }>();
const username = (body.username ?? '').trim();
const password = body.password ?? '';
const role = body.role as UserRole | undefined;
if (username === '' || password.length < 6) {
return c.json({ error: '用户名必填,密码至少 6 位' }, 400);
}
if (role === undefined || !USER_ROLES.includes(role)) {
return c.json({ error: '非法角色' }, 400);
}
const exists = await getUserByUsername(pool, username);
if (exists !== null) {
return c.json({ error: '用户名已存在' }, 409);
}
const user = await createUser(pool, { username, displayName: body.displayName ?? null, password, role });
return c.json(user);
});
/** 更新用户显示名/角色/启用状态(系统管理员)。 */
app.put('/api/users/:id', requireRole('系统管理员'), async (c) => {
if (pool === null) return c.json({ error: '未配置数据库' }, 400);
const id = c.req.param('id') as string;
const body = await c.req.json<{ displayName?: string | null; role?: string; active?: boolean }>();
const patch: { displayName?: string | null; role?: UserRole; active?: boolean } = {};
if (body.displayName !== undefined) patch.displayName = body.displayName;
if (body.role !== undefined) {
if (!USER_ROLES.includes(body.role as UserRole)) return c.json({ error: '非法角色' }, 400);
patch.role = body.role as UserRole;
}
if (body.active !== undefined) patch.active = body.active;
const updated = await updateUser(pool, id, patch);
if (updated === null) return c.json({ error: '用户不存在' }, 404);
return c.json(updated);
});
/** 重置用户密码(系统管理员)。 */
app.post('/api/users/:id/password', requireRole('系统管理员'), async (c) => {
if (pool === null) return c.json({ error: '未配置数据库' }, 400);
const id = c.req.param('id') as string;
const body = await c.req.json<{ password?: string }>();
if ((body.password ?? '').length < 6) return c.json({ error: '密码至少 6 位' }, 400);
await setUserPassword(pool, id, body.password as string);
return c.json({ reset: true });
});
/** 删除用户(系统管理员)。 */
app.delete('/api/users/:id', requireRole('系统管理员'), async (c) => {
if (pool === null) return c.json({ error: '未配置数据库' }, 400);
await deleteUser(pool, c.req.param('id') as string);
return c.json({ deleted: true });
});
/**
* GET /api/region-rates/engine-defaults
* 引擎内置默认费率(全国 + 上海/北京/广东),作为维护对照基准。
@@ -2127,6 +2220,7 @@ async function start(): Promise<void> {
const arc = await pg.loadArchived();
for (const [id, v] of arc) archivedById.set(id, v);
calibratedTargetBase = (await getSetting(pool, 'targetMarginBase')) ?? null;
await ensureSeedUsers(pool);
console.log(`PostgreSQL 持久化已启用,已加载 ${store.getAll().length} 条评估记录`);
} catch (err) {
console.error('PostgreSQL 连接失败,回退到进程内存储:', err instanceof Error ? err.message : err);