Files
delivery-desk/api/routes/management.ts
yuzhe b0c498fbb6 feat(auth): 添加认证模块和图片批注功能(项目初始化)
- 实现用户登录、登出、密码修改等认证功能
- 添加会话管理和权限控制中间件
- 创建图片批注组件和相关API路由
- 实现批注的增删改查功能
- 添加Docker和Git忽略配置文件
- 创建系统架构文档和开发约定说明
- 集成认证模块到前端应用路由中
2026-07-21 15:28:55 +08:00

58 lines
16 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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:'运营组名称需为 240 个字符'});return}if(!/^[A-Za-z0-9._-]{3,32}$/.test(username)){res.status(400).json({error:'账号需为 332 位字母、数字、点、横线或下划线'});return}if(displayName.length<2||displayName.length>40){res.status(400).json({error:'姓名需为 240 个字符'});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:'运营组名称需为 240 个字符'});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:'账号需为 332 位字母、数字、点、横线或下划线'});return}
if(displayName.length<2||displayName.length>40){res.status(400).json({error:'姓名需为 240 个字符'});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:'姓名需为 240 个字符'});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 名称需为 250 个字符'});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;