多用户与角色:新增系统管理员 + 用户管理
- 新增 users 表(scrypt 口令哈希)与持久化层,启动兜底种子账号 - 登录改为后端用户表校验账号密码;JWT 带角色;保留无DB演示回退 - 新增系统管理员角色 + 用户管理页(增删改/改角色/启停/重置密码) - 用户管理端点按 系统管理员 角色强制校验(RBAC) - 各角色可建任意多个账号(多销售/多风控/多管理) - 更新登录页快速登录与首屏快照
This commit is contained in:
+1
-1
@@ -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;
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user