- 实现用户登录、登出、密码修改等认证功能 - 添加会话管理和权限控制中间件 - 创建图片批注组件和相关API路由 - 实现批注的增删改查功能 - 添加Docker和Git忽略配置文件 - 创建系统架构文档和开发约定说明 - 集成认证模块到前端应用路由中
58 lines
16 KiB
TypeScript
58 lines
16 KiB
TypeScript
import { randomBytes } from 'crypto';
|
||
import { Router, type Response } from 'express';
|
||
import { audit, hashPassword, requireRole, sha256, type AuthRequest } from '../auth.js';
|
||
import { database, withTransaction } from '../database.js';
|
||
|
||
const router=Router();const passwordValid=(password:string)=>password.length>=8&&/[A-Za-z]/.test(password)&&/\d/.test(password);
|
||
|
||
router.get('/groups', requireRole('platform_admin'), async (_req, res) => {
|
||
const rows = await database.all<Record<string, unknown>>(`SELECT g.*,COALESCE(us.user_count,0) AS user_count,COALESCE(us.active_user_count,0) AS active_user_count,COALESCE(us.operator_count,0) AS operator_count,COALESCE(us.disabled_user_count,0) AS disabled_user_count,us.group_admin_name,us.group_admin_status,COALESCE(ps.project_count,0) AS project_count,COALESCE(ps.customer_link_count,0) AS customer_link_count FROM operation_groups g LEFT JOIN (SELECT group_id,COUNT(*) AS user_count,COUNT(CASE WHEN status='active' THEN 1 END) AS active_user_count,COUNT(CASE WHEN role='operator' THEN 1 END) AS operator_count,COUNT(CASE WHEN status='disabled' THEN 1 END) AS disabled_user_count,MAX(CASE WHEN role='group_admin' THEN display_name END) AS group_admin_name,MAX(CASE WHEN role='group_admin' THEN status END) AS group_admin_status FROM users WHERE group_id IS NOT NULL GROUP BY group_id) us ON us.group_id=g.id LEFT JOIN (SELECT group_id,COUNT(*) AS project_count,COUNT(CASE WHEN customer_access_enabled=TRUE THEN 1 END) AS customer_link_count FROM projects GROUP BY group_id) ps ON ps.group_id=g.id ORDER BY g.id DESC`);
|
||
res.json(rows.map((row) => ({ ...row, id: Number(row.id), user_count: Number(row.user_count), active_user_count:Number(row.active_user_count),operator_count:Number(row.operator_count),disabled_user_count:Number(row.disabled_user_count),project_count: Number(row.project_count),customer_link_count:Number(row.customer_link_count) })));
|
||
});
|
||
router.post('/groups',requireRole('platform_admin'),async(req:AuthRequest,res:Response)=>{const name=String(req.body?.name??'').trim();const username=String(req.body?.username??'').trim();const displayName=String(req.body?.display_name??'').trim();const password=String(req.body?.password??'');if(name.length<2||name.length>40){res.status(400).json({error:'运营组名称需为 2–40 个字符'});return}if(!/^[A-Za-z0-9._-]{3,32}$/.test(username)){res.status(400).json({error:'账号需为 3–32 位字母、数字、点、横线或下划线'});return}if(displayName.length<2||displayName.length>40){res.status(400).json({error:'姓名需为 2–40 个字符'});return}if(!passwordValid(password)){res.status(400).json({error:'临时密码至少 8 位,并同时包含字母和数字'});return}try{const created=await withTransaction(async(tx)=>{const groupId=await tx.insertId('INSERT INTO operation_groups (name) VALUES (?)',[name]);const userId=await tx.insertId("INSERT INTO users (group_id,username,display_name,password_hash,role) VALUES (?,?,?,?,'group_admin')",[groupId,username,displayName,hashPassword(password)]);return{groupId,userId}});await audit(req,'group.create','group',created.groupId,{name,firstAdminId:created.userId});res.status(201).json({id:created.groupId,name,status:'active',user_count:1,project_count:0})}catch{res.status(409).json({error:'运营组名称或账号已经存在'})}});
|
||
router.patch('/groups/:groupId/status',requireRole('platform_admin'),async(req:AuthRequest,res:Response)=>{const groupId=Number(req.params.groupId);const status=req.body?.status;if(!Number.isFinite(groupId)||!['active','disabled'].includes(status)){res.status(400).json({error:'无效的运营组或状态'});return}const changed=await withTransaction(async(tx)=>{const result=await tx.execute('UPDATE operation_groups SET status=? WHERE id=?',[status,groupId]);if(status==='disabled'&&result.changes){await tx.execute("UPDATE users SET status='disabled' WHERE group_id=?",[groupId]);await tx.execute('DELETE FROM sessions WHERE user_id IN (SELECT id FROM users WHERE group_id=?)',[groupId])}return result.changes});if(!changed){res.status(404).json({error:'运营组不存在'});return}await audit(req,`group.${status}`,'group',groupId);res.json({success:true,status})});
|
||
router.patch('/groups/:groupId',requireRole('platform_admin'),async(req:AuthRequest,res:Response)=>{const groupId=Number(req.params.groupId);const name=String(req.body?.name??'').trim();if(!Number.isFinite(groupId)){res.status(400).json({error:'无效的运营组'});return}if(name.length<2||name.length>40){res.status(400).json({error:'运营组名称需为 2–40 个字符'});return}try{const result=await database.execute('UPDATE operation_groups SET name=? WHERE id=?',[name,groupId]);if(!result.changes){res.status(404).json({error:'运营组不存在'});return}await audit(req,'group.rename','group',groupId,{name});res.json({id:groupId,name})}catch{res.status(409).json({error:'该运营组名称已经存在'})}});
|
||
|
||
router.get('/users', requireRole('platform_admin', 'group_admin'), async (req: AuthRequest, res: Response) => {
|
||
const isPlatform = req.authUser?.role === 'platform_admin';
|
||
const requestedGroup = Number(req.query.groupId);
|
||
const where = isPlatform && !Number.isFinite(requestedGroup) ? '' : 'WHERE u.group_id=?';
|
||
const params = where ? [isPlatform ? requestedGroup : req.authUser?.group_id] : [];
|
||
const rows = await database.all<Record<string, unknown>>(`SELECT u.id,u.group_id,g.name AS group_name,u.username,u.display_name,u.role,u.status,u.must_change_password,u.last_login_at,u.created_at,a.last_operation_at FROM users u LEFT JOIN operation_groups g ON g.id=u.group_id LEFT JOIN (SELECT user_id,MAX(created_at) AS last_operation_at FROM audit_logs GROUP BY user_id) a ON a.user_id=u.id ${where} ORDER BY CASE u.role WHEN 'platform_admin' THEN 0 WHEN 'group_admin' THEN 1 ELSE 2 END,g.name,u.display_name`, params);
|
||
res.json(rows.map((row) => ({ ...row, id: Number(row.id), group_id: row.group_id == null ? null : Number(row.group_id), must_change_password: Boolean(row.must_change_password) })));
|
||
});
|
||
router.post('/users',requireRole('platform_admin','group_admin'),async(req:AuthRequest,res:Response)=>{
|
||
const isPlatform=req.authUser?.role==='platform_admin';
|
||
const requestedRole=String(req.body?.role??'operator');
|
||
const role=isPlatform&&['platform_admin','group_admin','operator'].includes(requestedRole)?requestedRole:'operator';
|
||
const groupId=role==='platform_admin'?null:(isPlatform?Number(req.body?.group_id):req.authUser?.group_id);
|
||
const username=String(req.body?.username??'').trim();const displayName=String(req.body?.display_name??'').trim();const password=String(req.body?.password??'');
|
||
if(role!=='platform_admin'&&!Number.isFinite(groupId)){res.status(400).json({error:'请选择运营组'});return}
|
||
if(!/^[A-Za-z0-9._-]{3,32}$/.test(username)){res.status(400).json({error:'账号需为 3–32 位字母、数字、点、横线或下划线'});return}
|
||
if(displayName.length<2||displayName.length>40){res.status(400).json({error:'姓名需为 2–40 个字符'});return}
|
||
if(!passwordValid(password)){res.status(400).json({error:'临时密码至少 8 位,并同时包含字母和数字'});return}
|
||
if(role!=='platform_admin'&&!await database.one('SELECT id FROM operation_groups WHERE id=? AND status=?',[groupId,'active'])){res.status(400).json({error:'运营组不存在或已停用'});return}
|
||
if(role==='group_admin'&&await database.one('SELECT id FROM users WHERE group_id=? AND role=?',[groupId,'group_admin'])){res.status(409).json({error:'每个运营组只能有一位组管理员'});return}
|
||
try{const id=await database.insertId('INSERT INTO users (group_id,username,display_name,password_hash,role) VALUES (?,?,?,?,?)',[groupId,username,displayName,hashPassword(password),role]);await audit(req,'user.create','user',id,{groupId,username,role});res.status(201).json({id,group_id:groupId,username,display_name:displayName,role,status:'active',must_change_password:true})}catch{res.status(409).json({error:role==='group_admin'?'每个运营组只能有一位组管理员':'账号已经存在'})}
|
||
});
|
||
async function manageableUser(req:AuthRequest,userId:number){const row=await database.one<{id:number;group_id:number|null;role:string}>('SELECT id,group_id,role FROM users WHERE id=?',[userId]);if(!row||Number(row.id)===req.authUser?.id)return undefined;if(req.authUser?.role==='platform_admin')return row;return Number(row.group_id)===req.authUser?.group_id&&row.role==='operator'?row:undefined}
|
||
async function renameableUser(req:AuthRequest,userId:number){const row=await database.one<{id:number;group_id:number|null;role:string}>('SELECT id,group_id,role FROM users WHERE id=?',[userId]);if(!row)return undefined;if(req.authUser?.role==='platform_admin')return row;if(req.authUser?.role==='group_admin'&&Number(row.group_id)===req.authUser.group_id&&(Number(row.id)===req.authUser.id||row.role==='operator'))return row;return undefined}
|
||
router.patch('/users/:userId/name',requireRole('platform_admin','group_admin'),async(req:AuthRequest,res:Response)=>{const userId=Number(req.params.userId);const displayName=String(req.body?.display_name??'').trim();const target=await renameableUser(req,userId);if(!target){res.status(403).json({error:'不能修改该账号姓名'});return}if(displayName.length<2||displayName.length>40){res.status(400).json({error:'姓名需为 2–40 个字符'});return}await database.execute('UPDATE users SET display_name=? WHERE id=?',[displayName,userId]);await audit(req,'user.name_update','user',userId,{displayName});res.json({success:true,display_name:displayName})});
|
||
router.patch('/users/:userId/status',requireRole('platform_admin','group_admin'),async(req:AuthRequest,res:Response)=>{const userId=Number(req.params.userId);const status=req.body?.status;if(!['active','disabled'].includes(status)||!await manageableUser(req,userId)){res.status(403).json({error:'不能修改该账号'});return}await database.execute('UPDATE users SET status=? WHERE id=?',[status,userId]);if(status==='disabled')await database.execute('DELETE FROM sessions WHERE user_id=?',[userId]);await audit(req,`user.${status}`,'user',userId);res.json({success:true,status})});
|
||
router.post('/users/:userId/reset-password',requireRole('platform_admin','group_admin'),async(req:AuthRequest,res:Response)=>{const userId=Number(req.params.userId);const password=String(req.body?.password??'');if(!await manageableUser(req,userId)){res.status(403).json({error:'不能重置该账号密码'});return}if(!passwordValid(password)){res.status(400).json({error:'临时密码至少 8 位,并同时包含字母和数字'});return}await withTransaction(async(tx)=>{await tx.execute('UPDATE users SET password_hash=?,must_change_password=? WHERE id=?',[hashPassword(password),true,userId]);await tx.execute('DELETE FROM sessions WHERE user_id=?',[userId])});await audit(req,'user.password_reset','user',userId);res.json({success:true})});
|
||
|
||
router.post('/groups/:groupId/replace-admin',requireRole('platform_admin'),async(req:AuthRequest,res:Response)=>{const groupId=Number(req.params.groupId);const userId=Number(req.body?.user_id);const previousAction=req.body?.previous_action==='disable'?'disable':'demote';const next=await database.one<{id:number;display_name:string}>('SELECT id,display_name FROM users WHERE id=? AND group_id=? AND role=? AND status=?',[userId,groupId,'operator','active']);const current=await database.one<{id:number;display_name:string}>('SELECT id,display_name FROM users WHERE group_id=? AND role=?',[groupId,'group_admin']);if(!next||!current){res.status(400).json({error:'请选择本组一位已启用的光影叙事'});return}await withTransaction(async(tx)=>{await tx.execute('UPDATE users SET role=?,status=? WHERE id=?',['operator',previousAction==='disable'?'disabled':'active',current.id]);await tx.execute('UPDATE users SET role=? WHERE id=?',['group_admin',next.id])});await audit(req,'group.admin_replace','group',groupId,{previousAdminId:Number(current.id),nextAdminId:Number(next.id),previousAction});res.json({success:true,previous_admin:current.display_name,next_admin:next.display_name})});
|
||
|
||
router.get('/api-keys', requireRole('platform_admin', 'group_admin'), async (req: AuthRequest, res: Response) => {
|
||
const platform = req.authUser?.role === 'platform_admin';
|
||
const where = platform ? "k.scope='platform'" : "k.scope='project' AND k.group_id=?";
|
||
const params = platform ? [] : [req.authUser?.group_id];
|
||
const rows = await database.all<Record<string, unknown>>(`SELECT k.id,k.group_id,k.project_id,p.name AS project_name,k.name,k.key_prefix,k.scope,k.status,u.display_name AS created_by_name,k.last_used_at,k.created_at FROM api_keys k LEFT JOIN projects p ON p.id=k.project_id JOIN users u ON u.id=k.created_by WHERE ${where} ORDER BY k.id DESC`, params);
|
||
res.json(rows.map((row) => ({ ...row, id: Number(row.id), group_id: row.group_id == null ? null : Number(row.group_id), project_id: row.project_id == null ? null : Number(row.project_id) })));
|
||
});
|
||
router.post('/api-keys',requireRole('platform_admin','group_admin'),async(req:AuthRequest,res:Response)=>{const platform=req.authUser?.role==='platform_admin';const scope=platform?'platform':'project';const name=String(req.body?.name??'').trim();const projectId=scope==='project'?Number(req.body?.project_id):null;if(name.length<2||name.length>50){res.status(400).json({error:'Key 名称需为 2–50 个字符'});return}if(scope==='project'&&!await database.one('SELECT id FROM projects WHERE id=? AND group_id=?',[projectId,req.authUser?.group_id])){res.status(400).json({error:'请选择本组项目'});return}const token=`dd_live_${randomBytes(32).toString('base64url')}`;const prefix=`${token.slice(0,16)}…`;const id=await database.insertId('INSERT INTO api_keys (group_id,project_id,name,key_prefix,key_hash,scope,created_by) VALUES (?,?,?,?,?,?,?)',[scope==='project'?req.authUser?.group_id:null,projectId,name,prefix,sha256(token),scope,req.authUser?.id]);await audit(req,'api_key.create','api_key',id,{name,scope,projectId});res.status(201).json({token,item:{id,group_id:scope==='project'?req.authUser?.group_id:null,project_id:projectId,name,key_prefix:prefix,scope,status:'active',created_by_name:req.authUser?.display_name,last_used_at:null,created_at:new Date().toISOString()}})});
|
||
router.delete('/api-keys/:keyId',requireRole('platform_admin','group_admin'),async(req:AuthRequest,res:Response)=>{const keyId=Number(req.params.keyId);const platform=req.authUser?.role==='platform_admin';const key=await database.one<{id:number;group_id:number|null;scope:string}>('SELECT id,group_id,scope FROM api_keys WHERE id=?',[keyId]);const allowed=key&&(platform?key.scope==='platform':key.scope==='project'&&Number(key.group_id)===req.authUser?.group_id);if(!allowed){res.status(404).json({error:'API Key 不存在'});return}await database.execute("UPDATE api_keys SET status='revoked',revoked_at=? WHERE id=?",[new Date().toISOString(),keyId]);await audit(req,'api_key.revoke','api_key',keyId);res.status(204).end()});
|
||
|
||
router.get('/audit-logs',requireRole('platform_admin','group_admin'),async(req:AuthRequest,res:Response)=>{const conditions:string[]=[];const params:unknown[]=[];if(req.authUser?.role!=='platform_admin'){conditions.push('l.group_id=?');params.push(req.authUser?.group_id)}const userId=Number(req.query.userId);if(Number.isFinite(userId)){conditions.push('l.user_id=?');params.push(userId)}const where=conditions.length?`WHERE ${conditions.join(' AND ')}`:'';const rows=await database.all<Record<string,unknown>&{detail:string}>(`SELECT l.*,g.name AS group_name,u.display_name AS user_name FROM audit_logs l LEFT JOIN operation_groups g ON g.id=l.group_id LEFT JOIN users u ON u.id=l.user_id ${where} ORDER BY l.id DESC LIMIT 200`,params);res.json(rows.map((row)=>{try{return{...row,id:Number(row.id),group_id:row.group_id==null?null:Number(row.group_id),user_id:row.user_id==null?null:Number(row.user_id),detail:typeof row.detail==='string'?JSON.parse(row.detail):row.detail}}catch{return{...row,id:Number(row.id),detail:{}}}}))});
|
||
export default router;
|